overhaul thread model and backup reliability

Thread model:
  - replace the IOState CAS state machine with a StampedLock: region IO
    (RegionFileStorage.write/flush, now both guarded via @WrapMethod) holds
    the read lock, backups hold the write lock; the old witness-restore
    pattern could wedge the state permanently
  - run saveEverything on the main thread (was called from the backup
    executor), with shutdown-aware waiting; skip autoSave/saveEverything
    while a backup holds the write lock so the main thread never blocks
  - broadcast player messages from the main thread, honor `broadcast`

  Backup correctness:
  - watcher reported the parent directory instead of the changed file, so
    every incremental backup was empty; change sets are now deduplicated,
    atomically drained and merged back on failure; OVERFLOW forces a full
    backup; watch roots are re-registered when their inode changes (world
    format conversion orphaned the watches)
  - fix the region reassembly pipeline: zlib was "decompressed" with
    DeflaterInputStream, writeChunk dropped the 5-byte chunk header,
    the length field was off by one, coordinates were transposed, and
    sector padding was wrong on 4096-byte multiples — every reassembled
    chunk in old bundles is unrecoverable garbage
  - full backups now collect the file list after saving, off-thread

  New features:
  - reflink (FICLONE) staging via FFM: on CoW filesystems the write lock
    is only held while cloning, packing/uploading runs unlocked
  - chained restore: restoring an incremental automatically applies its
    full base and every incremental in between, in order
  - generation-based rotation implementing keepBackups; whole generations
    are deleted so an incremental never loses its base
  - Backup records carry subject/createdAt/incremental for chaining

  Reliability:
  - atomic index persistence (tmp + ATOMIC_MOVE), ConcurrentHashMap for
    the tracked-backup index, fsync bundles before storing
  - BundleReader: overlay extraction for chains, zip-slip guard, fix
    reversed createSymbolicLink args, EOF instead of infinite loop on
    truncated bundles
  - LocalBackupStrategy.deleteBackup compared the wrong field and could
    never delete; S3 retries now catch SdkException (retry was dead code),
    drop ifNoneMatch for idempotent retries
  - ordered shutdown: SERVER_STOPPING awaits in-flight backups before the
    final world save; SERVER_STOPPED runs restore, closes storage and
    cleans the temp dir with isolated error handling
  - fix /backup suspend being registered under /backup create

  Tests: region round-trip byte-comparison, BundleReader overlay/zip-slip/
  symlink/truncation, backup chain resolution and rotation.

  Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
iceBear67 committed 2026-07-17 19:35:43 +08:00
1 parent 592c79ed90
commit 34f7d15566
25 files changed
+1418 -367

No files matched your search

+7
View File
@@ -71,6 +71,13 @@ dependencies {
// https://mvnrepository.com/artifact/software.amazon.awssdk/s3
shadow project(":bundler")
shadow 'software.amazon.awssdk:s3:2.34.0'
testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
}
configurations.implementation.extendsFrom(configurations.shadow)
shadowJar {
@@ -6,7 +6,9 @@ import io.ib67.sfcraft.bundler.region.RegionParser;
import io.netty.buffer.ByteBufAllocator;
import lombok.Builder;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -32,8 +34,14 @@ public class BundleReader {
this.dict = dict;
}
/**
* Extracts a bundle into {@code destination}. Existing files are overwritten, which is
* what makes chained restores work: apply the full base first, then each incremental in
* order — later bundles win.
*/
public void extract(Path bundle, Path destination) throws IOException {
var readMaps = new HashMap<String, Long>();
var destRoot = destination.toAbsolutePath().normalize();
try (var zstd = new ZstdInputStreamNoFinalizer(Files.newInputStream(bundle, StandardOpenOption.READ));
var in = new EntryInputStream(zstd)) {
if (dict != null) zstd.setDict(dict);
@@ -42,62 +50,101 @@ public class BundleReader {
var time = entry.time();
if (time < minTime || time > maxTime) {
System.err.println("Skipping entry " + entry.name() + " (too old or too new)");
skipExactly(in, entry.length());
continue;
}
var lastSeen = readMaps.get(entry.name());
if (lastSeen != null && skipOldEntry && entry.time() < lastSeen) {
System.err.println("Skipping entry " + entry.name() + " (older than )");
System.err.println("Skipping entry " + entry.name() + " (older than seen)");
skipExactly(in, entry.length());
continue;
}
readMaps.put(entry.name(), time);
var path = destination.resolve(entry.name()); // todo prevent path underflow
var path = resolveSafely(destRoot, entry.name());
var parentDir = path.getParent();
if (Files.notExists(parentDir)) Files.createDirectories(parentDir);
if ((entry.attribute() & BundleEntry.ATTR_SYMLINK) != 0) {
var toRead = (int) entry.length();
var strArr = new byte[toRead];
while ((toRead -= in.read(strArr, (int) (entry.length() - toRead), toRead)) > 0) ;
var target = new String(strArr);
var targetPath = Path.of(target);
Files.createSymbolicLink(targetPath, path);
var strArr = new byte[(int) entry.length()];
readExactly(in, strArr);
var targetPath = Path.of(new String(strArr));
Files.deleteIfExists(path);
Files.createSymbolicLink(path, targetPath);
} else if ((entry.attribute() & BundleEntry.ATTR_REASSEMBLE) != 0) {
var entryData = allocator.buffer();
var toRead = entry.length();
var buffer = this.buffer;
while (toRead > 0) {
var read = in.read(buffer, 0, (int) Math.min(buffer.length, toRead));
toRead -= read;
entryData.writeBytes(buffer, 0, read);
}
try (var reassembler = new RegionParser(entryData, allocator)) {
var result = reassembler.writeReassembled(RegionFile.CompressType.ZLIB);
try (var fc = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
result.readBytes(fc, 0, result.readableBytes());
} finally {
result.release();
try {
var toRead = entry.length();
var buffer = this.buffer;
while (toRead > 0) {
var read = in.read(buffer, 0, (int) Math.min(buffer.length, toRead));
if (read < 0) throw new EOFException("Truncated bundle while reading " + entry.name());
toRead -= read;
entryData.writeBytes(buffer, 0, read);
}
} catch (Exception e) {
try (var ch = FileChannel.open(Path.of("dump.mca"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
entryData.readerIndex(0);
entryData.readBytes(ch, 0, entryData.readableBytes());
try (var reassembler = new RegionParser(entryData, allocator)) {
var result = reassembler.writeReassembled(RegionFile.CompressType.ZLIB);
try (var fc = FileChannel.open(path, StandardOpenOption.CREATE,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
result.readBytes(fc, 0, result.readableBytes());
} finally {
result.release();
}
} catch (Exception e) {
var dump = Files.createTempFile("justbackup_dump_", ".mca");
try (var ch = FileChannel.open(dump, StandardOpenOption.WRITE)) {
entryData.readerIndex(0);
entryData.readBytes(ch, 0, entryData.readableBytes());
}
throw new IOException("Error while reassembling " + entry.name() + ", dumped to " + dump, e);
}
throw new IOException("Error while reassembling " + entry.name(), e);
} finally {
entryData.release();
}
} else {
writePlain(path, entry, in);
}
}
}
}
/**
* Resolves an entry name inside the destination, refusing names that escape it
* (absolute paths or {@code ..} traversal).
*/
private static Path resolveSafely(Path destRoot, String entryName) throws IOException {
var resolved = destRoot.resolve(entryName).normalize();
if (!resolved.startsWith(destRoot)) {
throw new IOException("Entry " + entryName + " escapes the destination directory, refusing to extract");
}
return resolved;
}
private static void readExactly(InputStream in, byte[] target) throws IOException {
int off = 0;
while (off < target.length) {
int read = in.read(target, off, target.length - off);
if (read < 0) throw new EOFException("Truncated bundle");
off += read;
}
}
private void skipExactly(InputStream in, long length) throws IOException {
var buffer = this.buffer;
while (length > 0) {
int read = in.read(buffer, 0, (int) Math.min(buffer.length, length));
if (read < 0) throw new EOFException("Truncated bundle");
length -= read;
}
}
private void writePlain(Path path, BundleEntry entry, EntryInputStream in) throws IOException {
var buffer = this.buffer;
try (var fc = Files.newOutputStream(path, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
try (var fc = Files.newOutputStream(path, StandardOpenOption.CREATE,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
var toRead = entry.length();
while (toRead > 0) {
var read = in.read(buffer, 0, (int) Math.min(buffer.length, toRead));
if (read < 0) throw new EOFException("Truncated bundle while reading " + entry.name());
toRead -= read;
fc.write(buffer, 0, read);
}
@@ -12,9 +12,12 @@ import lombok.Builder;
import lombok.SneakyThrows;
import java.io.*;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.UnaryOperator;
@@ -131,14 +134,40 @@ public class BundleWriter implements Closeable {
Iterable<Path> path,
UnaryOperator<BundleWriterBuilder> config
) throws IOException {
try (var writer = config.apply(BundleWriter.builder())
.outputStream(Files.newOutputStream(pathToBundle))
.build()) {
for (var p : path) {
if (!Files.isRegularFile(p) || Files.size(p) == 0) continue;
var s = p.toString();
writer.write(p, (s.contains("region") && s.endsWith(".mca")) || s.endsWith(".dat"));
try (var channel = FileChannel.open(pathToBundle,
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
try (var writer = config.apply(BundleWriter.builder())
.outputStream(new NonClosingOutputStream(Channels.newOutputStream(channel)))
.build()) {
for (var p : path) {
if (!Files.isRegularFile(p) || Files.size(p) == 0) continue;
var s = p.toString();
writer.write(p, (s.contains("region") && s.endsWith(".mca")) || s.endsWith(".dat"));
}
}
// the zstd epilogue is flushed by writer.close(); fsync before closing so a crash
// right after "backup success" cannot leave a hollow bundle behind
channel.force(true);
}
}
/**
* Lets the zstd stream be closed (writing its epilogue) without closing the underlying
* channel, so the caller can fsync it afterwards.
*/
private static final class NonClosingOutputStream extends FilterOutputStream {
NonClosingOutputStream(OutputStream out) {
super(out);
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
out.write(b, off, len);
}
@Override
public void close() throws IOException {
out.flush();
}
}
@@ -191,8 +220,10 @@ public class BundleWriter implements Closeable {
result.readBytes(out, length);
result.release();
// all done
} catch (IOException e) {
System.err.println("Failed to parse region " + path + ": " + e);
} catch (Exception e) {
// any parse failure (not only IOException) must fall back to a plain copy instead
// of failing the whole backup
System.err.println("Failed to parse region " + path + ", storing it as-is: " + e);
finalResult.release();
writePlain(path);
return;
@@ -205,7 +236,8 @@ public class BundleWriter implements Closeable {
private void writePlain(Path path) throws IOException {
var rPath = relativeRoot.toAbsolutePath().relativize(path.toAbsolutePath());
var outputStream = this.outputStream;
outputStream.writeEntryHeader(rPath.toString(), (short) 0, Files.size(path), 0);
outputStream.writeEntryHeader(rPath.toString(), (short) 0, Files.size(path),
Files.getLastModifiedTime(path).toMillis());
var read = 0;
try (var fs = Files.newInputStream(path)) {
var buffer = BundleWriter.buffer.get();
@@ -45,31 +45,31 @@ public class RegionFile implements Closeable {
return (short) ((z & 0b111111) << 6 | (x & 0b111111));
}
// this retains the input bytebuf.
public void writeChunk(int chunkX, int chunkZ, CompressType type, ByteBuf data) throws IOException {
if (chunkX > 32 || chunkZ > 32 || chunkX < 0 || chunkZ < 0)
if (chunkX >= 32 || chunkZ >= 32 || chunkX < 0 || chunkZ < 0)
throw new IllegalArgumentException("Invalid chunkX+chunkZ: " + chunkX + " " + chunkZ);
var key = getChunkKey(chunkX, chunkZ);
var buf = allocator.buffer(5 + data.readableBytes());
ByteBuf payload;
if (type == CompressType.NONE) {
buf.writeInt(data.readableBytes());
buf.writeByte(type.index);
buf.writeBytes(data);
payload = allocator.buffer(data.readableBytes());
payload.writeBytes(data);
} else {
try (var out = findCompressor(type, new ByteBufOutputStream(buf))) {
while (data.readableBytes() > 0) data.readBytes(out, data.readableBytes());
var head = allocator.buffer(5);
head.writeInt(buf.readableBytes());
head.writeByte(type.index);
var composite = allocator.compositeBuffer();
composite.addComponent(true, head);
composite.addComponent(true, buf);
payload = allocator.buffer();
try (var out = findCompressor(type, new ByteBufOutputStream(payload))) {
data.readBytes(out, data.readableBytes());
} catch (IOException e) {
buf.release();
payload.release();
throw e;
}
}
regionData.put(key, buf);
var buf = allocator.buffer(5 + payload.readableBytes());
// vanilla region format: the length field also counts the compression type byte
buf.writeInt(payload.readableBytes() + 1);
buf.writeByte(type.index);
buf.writeBytes(payload);
payload.release();
var previous = regionData.put(key, buf);
if (previous != null) previous.release();
}
private OutputStream findCompressor(CompressType type, OutputStream out) throws IOException {
@@ -82,7 +82,7 @@ public class RegionFile implements Closeable {
}
public void updateChunkTime(int chunkX, int chunkZ, int timestamp) {
if (chunkX > 32 || chunkZ > 32 || chunkX < 0 || chunkZ < 0)
if (chunkX >= 32 || chunkZ >= 32 || chunkX < 0 || chunkZ < 0)
throw new IllegalArgumentException("Invalid chunkX+chunkZ: " + chunkX + " " + chunkZ);
var index = (chunkX + 32 * chunkZ) * 4;
this.timestamp.setInt(index, timestamp);
@@ -105,7 +105,7 @@ public class RegionFile implements Closeable {
var chunk = entry.getValue();
var size = chunk.readableBytes();
var sizeSectors = Math.ceilDiv(size, 4096);
var zeros = 4096 - (size % 4096);
var zeros = (4096 - (size % 4096)) % 4096;
for (int i = 0; i < zeros; i++) {
chunk.writeByte(0);
}
@@ -10,8 +10,8 @@ import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.zip.DeflaterInputStream;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;
public class RegionParser implements Closeable {
protected final ByteBuf source;
@@ -28,7 +28,9 @@ public class RegionParser implements Closeable {
try (var fc = FileChannel.open(file, StandardOpenOption.READ)) {
var toRead = size;
while (toRead > 0) {
toRead -= source.writeBytes(fc, 0L, (int) size);
var read = source.writeBytes(fc, (long) (size - toRead), toRead);
if (read < 0) throw new EOFException("Region file " + file + " shrank while reading");
toRead -= read;
}
}
}
@@ -98,8 +100,9 @@ public class RegionParser implements Closeable {
var key = chunks.getLong(i);
var chunkXZ = readChunkXZ(key);
var entry = readEntry(key);
short x = (short) (chunkXZ & 0x3F);
short z = (short) ((chunkXZ >>> 6) & 0x3F);
// packed as (x << 6) | z in readAllValidChunks
short x = (short) ((chunkXZ >>> 6) & 0x3F);
short z = (short) (chunkXZ & 0x3F);
var compressType = readChunk(entry, compressed);
try (var decompressor = getDecompressorByType(compressType, compressed)) {
while (uncompressed.writeBytes(decompressor, 4096) != -1) ;
@@ -116,7 +119,7 @@ public class RegionParser implements Closeable {
private InputStream getDecompressorByType(int compressionType, ByteBuf in) throws IOException {
return switch (compressionType) {
case 1 -> new GZIPInputStream(new ByteBufInputStream(in));
case 2 -> new DeflaterInputStream(new ByteBufInputStream(in));
case 2 -> new InflaterInputStream(new ByteBufInputStream(in));
case 3 -> new ByteBufInputStream(in);
case 4 -> new LZ4BlockInputStream(new ByteBufInputStream(in));
// user defined compression algorithm, not going to support.
@@ -0,0 +1,88 @@
package io.ib67.bundler;
import com.github.luben.zstd.ZstdOutputStreamNoFinalizer;
import io.ib67.sfcraft.bundler.BundleReader;
import io.ib67.sfcraft.bundler.BundleWriter;
import io.ib67.sfcraft.bundler.EntryOutputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.EOFException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class TestBundleReader {
@Test
public void testChainedOverlayRestore(@TempDir Path tmp) throws IOException {
var src = Files.createDirectories(tmp.resolve("src"));
Files.writeString(src.resolve("a.txt"), "version-1-that-is-long");
Files.writeString(src.resolve("b.txt"), "untouched");
var fullBundle = tmp.resolve("full.swb.zst");
BundleWriter.createBundle(fullBundle, List.of(src.resolve("a.txt"), src.resolve("b.txt")),
c -> c.relativeRoot(src));
// shorter content verifies the overlay truncates instead of leaving a stale tail
Files.writeString(src.resolve("a.txt"), "v2");
var incrBundle = tmp.resolve("incr.swb.zst");
BundleWriter.createBundle(incrBundle, List.of(src.resolve("a.txt")),
c -> c.relativeRoot(src));
var dest = tmp.resolve("dest");
var reader = BundleReader.builder().build();
reader.extract(fullBundle, dest);
reader.extract(incrBundle, dest);
assertEquals("v2", Files.readString(dest.resolve("a.txt")));
assertEquals("untouched", Files.readString(dest.resolve("b.txt")));
}
@Test
public void testZipSlipIsRejected(@TempDir Path tmp) throws IOException {
var bundle = tmp.resolve("evil.swb.zst");
var payload = "owned".getBytes();
try (var zstd = new ZstdOutputStreamNoFinalizer(Files.newOutputStream(bundle), 3);
var out = new EntryOutputStream(zstd)) {
out.writeEntryHeader("../evil.txt", (short) 0, payload.length, System.currentTimeMillis());
out.write(payload);
}
var dest = tmp.resolve("dest");
var ex = assertThrows(IOException.class,
() -> BundleReader.builder().build().extract(bundle, dest));
assertTrue(ex.getMessage().contains("escapes"), ex.getMessage());
assertFalse(Files.exists(tmp.resolve("evil.txt")));
}
@Test
public void testSymlinkIsCreatedAtLinkPath(@TempDir Path tmp) throws IOException {
var src = Files.createDirectories(tmp.resolve("src"));
var outside = Files.writeString(tmp.resolve("outside.txt"), "outside");
var link = src.resolve("link");
Files.createSymbolicLink(link, outside);
var bundle = tmp.resolve("sym.swb.zst");
BundleWriter.createBundle(bundle, List.of(link), c -> c.relativeRoot(src));
var dest = tmp.resolve("dest");
BundleReader.builder().build().extract(bundle, dest);
assertTrue(Files.isSymbolicLink(dest.resolve("link")),
"the symlink itself must be recreated at the link's path, not at its target");
}
@Test
public void testTruncatedEntryThrowsInsteadOfLooping(@TempDir Path tmp) throws IOException {
var bundle = tmp.resolve("truncated.swb.zst");
try (var zstd = new ZstdOutputStreamNoFinalizer(Files.newOutputStream(bundle), 3);
var out = new EntryOutputStream(zstd)) {
out.writeEntryHeader("short.bin", (short) 0, 100, System.currentTimeMillis());
out.write(new byte[10]); // 90 bytes missing
}
var dest = tmp.resolve("dest");
assertThrows(EOFException.class,
() -> BundleReader.builder().build().extract(bundle, dest));
}
}
@@ -0,0 +1,91 @@
package io.ib67.bundler;
import io.ib67.sfcraft.bundler.region.RegionFile;
import io.ib67.sfcraft.bundler.region.RegionParser;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Simulates the full backup->restore cycle for region files: vanilla mca -> reassembled
* uncompressed (what goes into the bundle) -> reassembled ZLIB (what restore writes back),
* then verifies every chunk's uncompressed NBT is byte-identical and lives in the same
* header slot as in the original file.
*/
public class TestRegionRoundTrip {
@Test
public void roundTripPreservesEveryChunk() throws IOException {
roundTrip("region/r.9.-5.mca");
roundTrip("region/r.-1.-1.mca");
}
private static void roundTrip(String resource) throws IOException {
byte[] originalBytes;
try (var in = TestRegionRoundTrip.class.getClassLoader().getResourceAsStream(resource)) {
originalBytes = in.readAllBytes();
}
var alloc = ByteBufAllocator.DEFAULT;
// backup side: vanilla -> NONE bundle payload
ByteBuf intermediate;
try (var parser = new RegionParser(Unpooled.wrappedBuffer(originalBytes), alloc)) {
intermediate = parser.writeReassembled(RegionFile.CompressType.NONE);
}
// restore side: bundle payload -> ZLIB region written to disk
ByteBuf restored;
try (var parser = new RegionParser(intermediate, alloc)) {
restored = parser.writeReassembled(RegionFile.CompressType.ZLIB);
} finally {
intermediate.release();
}
try (var originalParser = new RegionParser(Unpooled.wrappedBuffer(originalBytes), alloc);
var restoredParser = new RegionParser(restored.readerIndex(0).retain(), alloc)) {
var originalChunks = collect(originalParser);
var restoredChunks = collect(restoredParser);
assertFalse(originalChunks.isEmpty(), resource + " should contain chunks");
assertEquals(originalChunks.keySet(), restoredChunks.keySet(),
"restored file must populate exactly the same chunk slots (no transposition)");
for (var entry : originalChunks.entrySet()) {
var origNbt = decompress(originalParser, entry.getValue(), alloc);
var restNbt = decompress(restoredParser, restoredChunks.get(entry.getKey()), alloc);
try {
assertEquals(origNbt, restNbt, "chunk " + entry.getKey() + " must survive the round trip unchanged");
} finally {
origNbt.release();
restNbt.release();
}
}
// timestamp table is copied verbatim
var origTimes = Unpooled.wrappedBuffer(originalBytes).slice(4096, 4096);
var restTimes = restored.slice(4096, 4096);
assertEquals(origTimes, restTimes, "chunk timestamps must survive the round trip");
} finally {
restored.release();
}
}
/** chunkXZ -> header entry */
private static Map<Short, Integer> collect(RegionParser parser) {
var result = new HashMap<Short, Integer>();
var chunks = parser.readAllValidChunks();
for (int i = 0; i < chunks.size(); i++) {
var key = chunks.getLong(i);
result.put(RegionParser.readChunkXZ(key), RegionParser.readEntry(key));
}
return result;
}
private static ByteBuf decompress(RegionParser parser, int entry, ByteBufAllocator alloc) throws IOException {
var buf = alloc.buffer();
parser.readChunkDecompress(entry, buf);
return buf;
}
}
+28 -4
View File
@@ -2,17 +2,41 @@ package io.ib67.sfcraft;
import net.minecraft.network.chat.Component;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
/**
* @param subject name of the backup subject this bundle belongs to; null on entries created
* by older versions (those are treated as standalone full backups)
* @param createdAt epoch millis of creation; 0 on legacy entries
*/
public record Backup(
String name,
String backupKey,
String type,
String from,
String subject,
boolean incremental,
long createdAt,
long sizeTotal
) {
public Component toText(){
return Component.nullToEmpty("sizeTotal: "+Math.round(((sizeTotal / 1024.0) / 1024.0) * 100) * 0.01 + "MiB, "+
(incremental ? "incremental" :"") + ", type: "+type+"."
);
private static final DateTimeFormatter TIME_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
public boolean legacy() {
return subject == null || createdAt <= 0;
}
public Component toText() {
var size = Math.round(((sizeTotal / 1024.0) / 1024.0) * 100) * 0.01;
var sb = new StringBuilder()
.append(incremental ? "INCR" : "FULL")
.append(", ").append(size).append("MiB")
.append(", type: ").append(type);
if (createdAt > 0) {
sb.append(", at ").append(TIME_FORMAT.format(Instant.ofEpochMilli(createdAt)));
}
return Component.nullToEmpty(sb.toString());
}
}
@@ -7,19 +7,20 @@ import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import io.ib67.sfcraft.strategy.BackupChains;
import lombok.extern.log4j.Log4j2;
import net.minecraft.commands.CommandBuildContext;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.Component;
import java.awt.*;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static net.minecraft.commands.Commands.argument;
@@ -28,7 +29,7 @@ import static net.minecraft.commands.Commands.literal;
@Log4j2
public class BackupCommands {
protected final JustBackupMod mod;
protected Runnable restoreIssued;
protected volatile Runnable restoreIssued;
protected final List<String> helpMessage = List.of(
"__ Help messages of JustBackup __",
" /backup help ",
@@ -37,6 +38,8 @@ public class BackupCommands {
" -- List backups",
" /backup restore <backupKey>",
" -- Restore the backup on next server restart.",
" Restoring an incremental backup automatically applies its full base",
" and every incremental in between, in order.",
" Can be cancelled by /backup cancelrestore",
" /backup delete <backupKey>",
" -- Delete a backup. This does not ask for a confirmation.",
@@ -62,6 +65,7 @@ public class BackupCommands {
.then(literal("help").executes(this::cmdHelp))
.then(literal("list").executes(this::cmdList))
.then(literal("cancelrestore").executes(this::cmdCancelRestore))
.then(literal("suspend").executes(this::cmdSuspend))
.then(literal("delete")
.then(argument("backupKey", StringArgumentType.greedyString())
.suggests(this::suggestBackups)
@@ -74,9 +78,7 @@ public class BackupCommands {
.then(argument("incremental", BoolArgumentType.bool())
.then(argument("subject", StringArgumentType.greedyString())
.executes(this::createBackupBySubject))
.then(literal("@all").executes(this::fullBackup)))
.then(literal("suspend").executes(this::cmdSuspend))
));
.then(literal("@all").executes(this::fullBackup)))));
}
private int createBackupBySubject(CommandContext<CommandSourceStack> context) {
@@ -94,18 +96,23 @@ public class BackupCommands {
s.sendSystemMessage(Component.literal("The issued backup has been scheduled.").withColor(Color.CYAN.getRGB()));
var weakRef = new WeakReference<>(s);
mod.backupAll(incremental).thenAccept(result -> {
var ref = weakRef.get();
if (ref == null) {
ref = mod.server.createCommandSourceStack();
}
ref.sendSystemMessage(Component.literal("-- BACKUP RESULT SUMMARY --"));
for (var entry : result.entrySet()) {
var k = entry.getKey();
var v = entry.getValue();
ref.sendSystemMessage(Component.literal(" - [" + k + "]: ").append(v.toText()));
ref.sendSystemMessage(Component.literal("BACKUP KEY: " + v.backupKey()).withColor(Color.GRAY.getRGB()));
ref.sendSystemMessage(Component.literal("GENERATED FROM: " + v.from()).withColor(Color.GRAY.getRGB()));
}
var server = mod.server;
if (server == null || !server.isRunning()) return;
// completion runs on the backup executor; command sources must be used on the main thread
server.submit(() -> {
var ref = weakRef.get();
if (ref == null) {
ref = server.createCommandSourceStack();
}
ref.sendSystemMessage(Component.literal("-- BACKUP RESULT SUMMARY --"));
for (var entry : result.entrySet()) {
var k = entry.getKey();
var v = entry.getValue();
ref.sendSystemMessage(Component.literal(" - [" + k + "]: ").append(v.toText()));
ref.sendSystemMessage(Component.literal("BACKUP KEY: " + v.backupKey()).withColor(Color.GRAY.getRGB()));
ref.sendSystemMessage(Component.literal("GENERATED FROM: " + v.from()).withColor(Color.GRAY.getRGB()));
}
});
});
return Command.SINGLE_SUCCESS;
}
@@ -136,31 +143,51 @@ public class BackupCommands {
s.sendSystemMessage(Component.literal("Invalid backup " + backupKey + ". Note: Use backupKey instead of backupName!"));
return 0;
}
final List<Backup> chain;
try {
chain = BackupChains.resolveChain(mod.tracker.getTrackedBackups().values(), backup);
} catch (IllegalStateException e) {
s.sendSystemMessage(Component.literal(e.getMessage()).withColor(Color.RED.getRGB()));
return 0;
}
s.sendSystemMessage(Component.literal("The following " + chain.size() + " bundle(s) will be applied in order:"));
for (var b : chain) {
s.sendSystemMessage(Component.literal(" -> [" + (b.incremental() ? "INCR" : "FULL") + "] " + b.backupKey())
.withColor(Color.GRAY.getRGB()));
}
restoreIssued = () -> {
log.warn("EXTRACTING BACKUP " + backup + " TO " + backup.from());
log.warn(" == STEP 1 == Make a backup for the destination.");
var destination = Path.of(backup.from());
var movedDst = destination.resolveSibling(destination.getFileName().toString()+"_bak_"+System.currentTimeMillis());
try{
var movedDst = destination.resolveSibling(destination.getFileName().toString() + "_bak_" + System.currentTimeMillis());
log.warn("EXTRACTING {} BUNDLE(S) TO {}", chain.size(), destination);
log.warn(" == STEP 1 == Make a backup for the destination.");
try {
Files.move(destination, movedDst);
log.info("{} has been moved to {}", destination, movedDst);
} catch (IOException e) {
log.error(e);
log.error("CANNOT MOVE "+destination+" TO "+movedDst);
log.error("BACKUP INTERRUPTED. Here are some tips helping you out:");
log.error("CANNOT MOVE {} TO {}", destination, movedDst, e);
log.error("RESTORE INTERRUPTED. Here are some tips helping you out:");
log.error(" -- A. Merge already moved files");
log.error(" Try this command in your server directory: (linux)");
log.error(" $ cp ./"+movedDst+"/* ./"+destination);
log.error(" $ cp ./{}/* ./{}", movedDst, destination);
log.error(" -- B. Remove them all and use external unbundler tools");
log.error(" You may want to copy them elsewhere first (see kind A)");
log.error(" $ rm -r ./"+destination+" ./"+movedDst);
log.error(" $ rm -r ./{} ./{}", destination, movedDst);
log.error(" Visit https://github.com/saltedfishclub/justbackup for the usage of bundler tools");
log.error(" ------- ERROR END -------");
return;
}
log.info(" == STEP 2 == Recovering backup "+backup);
mod.tracker.recoverBackup(backup, destination);
log.info("Backup has been recovered.");
log.info(" == STEP 2 == Applying {} bundle(s)", chain.size());
try {
for (int i = 0; i < chain.size(); i++) {
var b = chain.get(i);
log.info("Applying {} ({}/{})", b.backupKey(), i + 1, chain.size());
mod.tracker.recoverBackup(b, destination);
}
log.info("Backup has been recovered.");
} catch (Exception e) {
log.error("RESTORE FAILED midway. Your original world is intact at {}", movedDst, e);
log.error("Move it back to {} to undo the restore.", destination);
}
};
s.sendSystemMessage(Component.literal("Backup restoration task has been scheduled!"));
s.sendSystemMessage(Component.literal("Restart your server to take changes."));
@@ -180,7 +207,7 @@ public class BackupCommands {
source.sendSystemMessage(Component.nullToEmpty("Backup has been deleted."));
} catch (Exception e) {
source.sendSystemMessage(Component.nullToEmpty("Backup has not been deleted successfully. ERROR: " + e));
log.error(e);
log.error("Cannot delete backup {}", backupKey, e);
}
return Command.SINGLE_SUCCESS;
}
@@ -1,11 +1,64 @@
package io.ib67.sfcraft;
import java.nio.file.Path;
import java.util.List;
import java.util.HashSet;
import java.util.Set;
public record BackupSubject(
String name,
Path root,
List<Path> changedFiles
) {
/**
* A directory tree being backed up, plus the set of files the watcher saw change since the
* last incremental backup. All access to the change set goes through synchronized methods so
* the watcher's adds and the worker's drain can never interleave badly (no lost events, no
* ConcurrentModificationException while bundling).
*/
public final class BackupSubject {
private final String name;
private final Path root;
private final Set<Path> changedFiles = new HashSet<>();
private boolean forceFullNext;
public BackupSubject(String name, Path root) {
this.name = name;
this.root = root;
}
public String name() {
return name;
}
public Path root() {
return root;
}
public synchronized void addChanged(Path file) {
changedFiles.add(file);
}
/**
* Atomically takes ownership of the accumulated change set, leaving an empty one behind.
* If the backup fails, give the files back via {@link #mergeBack(Set)}.
*/
public synchronized Set<Path> drainChanged() {
var drained = new HashSet<>(changedFiles);
changedFiles.clear();
return drained;
}
public synchronized void mergeBack(Set<Path> files) {
changedFiles.addAll(files);
}
/**
* Marks the next backup as full, used when the watcher reports an OVERFLOW (events were
* dropped by the OS, so the change set can no longer be trusted).
*/
public synchronized void forceFullNext() {
forceFullNext = true;
}
public synchronized boolean consumeForceFull() {
var v = forceFullNext;
forceFullNext = false;
if (v) changedFiles.clear();
return v;
}
}
+129 -19
View File
@@ -6,21 +6,45 @@ import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import net.minecraft.server.MinecraftServer;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
import java.util.stream.Stream;
import static io.ib67.sfcraft.Globals.BACKUP_LOCK;
import static io.ib67.sfcraft.Globals.WORLD_IO_LOCK;
/**
* Runs on the backup executor thread. Flow:
* <ol>
* <li>Save the world on the main thread (saveEverything is not thread-safe anywhere else)
* with flush=true, draining the IOWorkers so region files are complete on disk.</li>
* <li>Acquire the write side of {@link Globals#WORLD_IO_LOCK}, parking all region IO.</li>
* <li>Reflink-clone the files to a staging dir and release the lock early (CoW
* filesystems), or keep holding it while packing (fallback).</li>
* <li>Hand the bundle to the storage strategy.</li>
* </ol>
* The main thread never blocks on the lock — autoSave/saveEverything are skipped while it is
* write-locked (see MixinMinecraftServer) — so waiting for main-thread tasks while holding
* the write lock cannot deadlock.
*/
@Log4j2
public record BackupWorker(
MinecraftServer server,
BackupStrategy strategy,
Path tmpDir,
BackupSubject subject,
boolean incremental,
List<Path> excludedRoots,
UnaryOperator<BundleWriter.BundleWriterBuilder> config
) implements Supplier<Backup> {
private static final long LOCK_TIMEOUT_SECONDS = 60;
@SneakyThrows
public BackupWorker {
@@ -33,30 +57,116 @@ public record BackupWorker(
if (server == null) {
throw new IllegalStateException("MinecraftServer is not initialized yet, not doing backup");
}
server.saveEverything(true, true, false);
while (!BACKUP_LOCK.compareAndSet(IOState.IDLE, IOState.BACKUP)) {
log.info("Server is saving world while performing backup! waiting...");
Thread.sleep(2000);
var createdAt = System.currentTimeMillis();
var full = !incremental || subject.consumeForceFull();
runOnMainThread(() -> server.saveEverything(true, true, false), "world save");
long stamp = WORLD_IO_LOCK.tryWriteLock(LOCK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (stamp == 0) {
throw new IllegalStateException("World IO is still busy after " + LOCK_TIMEOUT_SECONDS
+ "s, aborting backup of " + subject.name());
}
// performing backup is now true
var tmpBundle = tmpDir.resolve("backup_" + subject.name() + "_" + System.currentTimeMillis() + ".swb.zst");
var tmpBundle = tmpDir.resolve("backup_" + subject.name() + "_" + createdAt + ".swb.zst");
Set<Path> drained = null;
Path stagingRoot = null;
try {
// tmp file path
log.info("Creating bundle {}", tmpBundle);
BundleWriter.createBundle(tmpBundle, subject.changedFiles(), config);
// Barrier: a main-thread save that started before we locked has finished by now.
runOnMainThread(() -> {
}, "main-thread barrier");
List<Path> files;
if (full) {
files = collectFullFileList();
} else {
drained = subject.drainChanged();
files = drained.stream().filter(this::eligible).toList();
}
var packRoot = subject.root();
stagingRoot = ReflinkCloner.tryStage(subject.name(), packRoot, files,
tmpDir.resolve("staging").resolve(subject.name() + "_" + createdAt));
if (stagingRoot != null) {
var absRoot = packRoot.toAbsolutePath().normalize();
var absStaging = stagingRoot;
files = files.stream()
.map(f -> absStaging.resolve(absRoot.relativize(f.toAbsolutePath().normalize())))
.toList();
packRoot = stagingRoot;
WORLD_IO_LOCK.unlockWrite(stamp);
stamp = 0;
log.info("Reflinked {} files to staging; world IO resumed, packing without the lock.", files.size());
}
log.info("Creating {} bundle {} ({} files)", full ? "full" : "incremental", tmpBundle, files.size());
var finalPackRoot = packRoot;
BundleWriter.createBundle(tmpBundle, files, c -> config.apply(c).relativeRoot(finalPackRoot));
} catch (Exception e) {
if (drained != null) subject.mergeBack(drained);
Files.deleteIfExists(tmpBundle);
throw e;
} finally {
// Though we won't access the state within backup progress, there are certain cases
// that this state will be changed to STORAGE_SYNC.
// Also take a look at MixinRegionBasedStorage.
while (!BACKUP_LOCK.compareAndSet(IOState.BACKUP, IOState.IDLE)) {
log.error("Expect BACKUP state but got {}", BACKUP_LOCK.get());
Thread.sleep(2000);
if (stamp != 0) WORLD_IO_LOCK.unlockWrite(stamp);
if (stagingRoot != null) {
try {
FsUtil.deleteRecursively(stagingRoot);
} catch (IOException e) {
log.warn("Cannot clean up staging dir {}", stagingRoot, e);
}
}
}
var result = strategy.createBackup(subject.name(), subject.root(), tmpBundle);
Files.deleteIfExists(tmpBundle);
return result;
try {
return strategy.createBackup(subject.name(), subject.root(), tmpBundle, !full, createdAt);
} catch (Exception e) {
if (drained != null) subject.mergeBack(drained);
throw e;
} finally {
Files.deleteIfExists(tmpBundle);
}
}
/**
* Runs a task on the main thread, but never hangs: once the server leaves its tick loop
* (shutdown) submitted tasks are no longer processed, so we poll and abort the backup
* instead of blocking forever.
*/
private void runOnMainThread(Runnable task, String what) {
var future = server.submit(task);
while (true) {
try {
future.get(1, TimeUnit.SECONDS);
return;
} catch (TimeoutException e) {
if (!server.isRunning()) {
future.cancel(false);
throw new IllegalStateException("Server is shutting down, aborting backup while waiting for " + what);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting for " + what);
} catch (ExecutionException e) {
throw new IllegalStateException(what + " failed", e.getCause());
}
}
}
@SneakyThrows
private List<Path> collectFullFileList() {
try (Stream<Path> s = Files.walk(subject.root())) {
return s.filter(Files::isRegularFile)
.filter(this::eligible)
.toList();
}
}
private boolean eligible(Path path) {
if (path.getFileName().toString().equals("session.lock")) return false;
var abs = path.toAbsolutePath().normalize();
for (Path excluded : excludedRoots) {
if (abs.startsWith(excluded.toAbsolutePath().normalize())) return false;
}
return true;
}
}
@@ -3,87 +3,181 @@ package io.ib67.sfcraft;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Log4j2
public class FileWatcherThread extends Thread {
protected final Map<Path, String> watchSubject;
protected final BiConsumer<String, Path> eventListener;
private static final long VALIDATE_INTERVAL_MILLIS = 10_000;
public FileWatcherThread(
Map<String, Path> watchSubject,
BiConsumer<String, Path> eventListener
) {
this.watchSubject = watchSubject.entrySet()
.stream()
// todo support for nonexist dir
.flatMap(it -> walkThrough(it.getValue()).map(r -> Map.entry(r.toAbsolutePath(), it.getKey())))
.filter(it->Files.isDirectory(it.getKey()))
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue
));
this.eventListener = eventListener;
setName("JustBackup/FileWatcher");
setDaemon(true);
public interface Listener {
void onChange(String subject, Path file);
/**
* Events for this subject were lost (OS queue overflow, or its directory tree was
* replaced underneath us); the accumulated change set can no longer be trusted and
* the next backup should be a full one.
*/
void onOverflow(String subject);
}
/** subject name -> root directory (absolute) */
protected final Map<String, Path> subjectRoots = new HashMap<>();
/** subject name -> fileKey (inode) of the root at registration time */
protected final Map<String, Object> subjectRootKeys = new HashMap<>();
/** watched directory (absolute) -> subject name */
protected final Map<Path, String> watchedDirs = new HashMap<>();
protected final Map<WatchKey, Path> keyToDir = new HashMap<>();
protected final Listener listener;
private WatchService watcher;
private long lastValidation;
@SneakyThrows
private static Stream<Path> walkThrough(Path path) {
if(Files.notExists(path)){
log.error("{} not found. Creating directory for it..", path);
Files.createDirectories(path);
public FileWatcherThread(
Map<String, Path> watchSubject,
Listener listener
) {
for (var entry : watchSubject.entrySet()) {
var root = entry.getValue();
if (Files.notExists(root)) {
log.error("{} not found. Creating directory for it..", root);
Files.createDirectories(root);
}
subjectRoots.put(entry.getKey(), root.toAbsolutePath());
}
return Files.walk(path);
this.listener = listener;
setName("JustBackup/FileWatcher");
setDaemon(true);
}
@Override
@SneakyThrows
public void run() {
try (var watcher = FileSystems.getDefault().newWatchService()) {
var keyToParent = new HashMap<WatchKey, Path>();
for (Path value : watchSubject.keySet()) {
var key = value.register(
watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY
);
keyToParent.put(key, value);
try (var watchService = FileSystems.getDefault().newWatchService()) {
this.watcher = watchService;
for (var entry : subjectRoots.entrySet()) {
registerSubject(entry.getKey(), false);
}
log.info("Watching {} directories for {} subjects", keyToDir.size(), subjectRoots.size());
lastValidation = System.currentTimeMillis();
for (; ; ) {
var key = watcher.poll(10, TimeUnit.SECONDS);
if (key == null) continue;
if (Thread.interrupted()) return;
if (System.currentTimeMillis() - lastValidation >= VALIDATE_INTERVAL_MILLIS) {
validateRoots();
}
if (key == null) continue;
var dir = keyToDir.get(key);
var subject = dir == null ? null : watchedDirs.get(dir);
for (var ev : key.pollEvents()) {
var kind = ev.kind();
if (kind == StandardWatchEventKinds.OVERFLOW)
continue;
var parent = keyToParent.get(key);
var subject = watchSubject.get(parent);
if(subject == null) {
log.warn("Ignoring changes in "+parent);
if (subject == null) {
log.warn("Ignoring changes in unknown watch key {}", dir);
continue;
}
var path = parent.resolve(((WatchEvent<Path>) ev).context());
var kind = ev.kind();
if (kind == StandardWatchEventKinds.OVERFLOW) {
log.warn("Watch events for {} overflowed; forcing a full backup on next run.", subject);
listener.onOverflow(subject);
continue;
}
var path = dir.resolve((Path) ev.context());
log.debug("event {} on {} ({})", kind, path, subject);
if (Files.isRegularFile(path)) {
eventListener.accept(subject, parent);
listener.onChange(subject, path);
} else if (Files.isDirectory(path) && kind == StandardWatchEventKinds.ENTRY_CREATE) {
var nK = path.register(watcher, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY);
watchSubject.put(path, subject);
keyToParent.put(nK, path);
// report contained files too: they may have been created before we
// managed to register and would never produce their own events
registerTree(path, subject, true);
}
}
key.reset();
if (!key.reset()) {
log.debug("Watch key for {} is no longer valid, dropping it", dir);
keyToDir.remove(key);
if (dir != null) watchedDirs.remove(dir);
}
}
}catch (InterruptedException interruptedException){
} catch (InterruptedException interruptedException) {
log.info("FileWatcherThread is quitting!");
}
}
/**
* Watch keys silently go stale when a watched root is replaced rather than modified —
* e.g. a world format conversion moves the directory aside and recreates it, leaving us
* watching an orphaned inode. Compare the root's fileKey (inode) against what we
* registered; on mismatch, re-register the whole subject and force a full backup since
* events were lost in between.
*/
private void validateRoots() {
lastValidation = System.currentTimeMillis();
for (var entry : subjectRoots.entrySet()) {
var subject = entry.getKey();
var root = entry.getValue();
Object currentKey = fileKeyOf(root);
if (java.util.Objects.equals(currentKey, subjectRootKeys.get(subject))) continue;
log.warn("Root of subject {} ({}) was replaced or recreated; re-registering watches and forcing a full backup.",
subject, root);
registerSubject(subject, true);
}
}
private static Object fileKeyOf(Path path) {
try {
return Files.readAttributes(path, BasicFileAttributes.class).fileKey();
} catch (IOException e) {
return null;
}
}
/**
* (Re-)registers a subject's whole directory tree. Existing watches of the subject are
* cancelled first; when {@code lostEvents} is set, the subject is flagged for a full
* backup because changes may have happened while we were not watching.
*/
private void registerSubject(String subject, boolean lostEvents) {
keyToDir.entrySet().removeIf(e -> {
if (subject.equals(watchedDirs.get(e.getValue()))) {
e.getKey().cancel();
return true;
}
return false;
});
watchedDirs.values().removeIf(subject::equals);
var root = subjectRoots.get(subject);
subjectRootKeys.put(subject, fileKeyOf(root));
if (Files.isDirectory(root)) {
// no need to report existing files: the startup full backup (initial call) or the
// forced full backup (lostEvents) already covers them
registerTree(root, subject, false);
} else {
log.error("Root of subject {} ({}) does not exist, nothing is watched for it.", subject, root);
}
if (lostEvents) listener.onOverflow(subject);
}
private void registerTree(Path root, String subject, boolean reportFiles) {
try (Stream<Path> walk = Files.walk(root)) {
for (var p : walk.toList()) {
if (Files.isDirectory(p)) {
var abs = p.toAbsolutePath();
var key = abs.register(watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY);
watchedDirs.put(abs, subject);
keyToDir.put(key, abs);
} else if (reportFiles && Files.isRegularFile(p)) {
listener.onChange(subject, p);
}
}
} catch (IOException e) {
log.warn("Cannot watch directory tree {}; forcing a full backup on next run.", root, e);
listener.onOverflow(subject);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
package io.ib67.sfcraft;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
public final class FsUtil {
private FsUtil() {
}
public static void deleteRecursively(Path root) throws IOException {
if (Files.notExists(root)) return;
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.deleteIfExists(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
if (exc != null) throw exc;
Files.deleteIfExists(dir);
return FileVisitResult.CONTINUE;
}
});
}
}
+16 -8
View File
@@ -6,18 +6,26 @@ import io.ib67.sfcraft.config.StorageOption;
import io.ib67.sfcraft.config.serializer.StorageOptionSerializer;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.StampedLock;
public class Globals {
/**
* for api users: DO NOT MUTATE THIS STATE IN WHATEVER CASE SINCE THIS IS CAUTIOUSLY ALIGNED WITH THE CURRENT THREAD MODEL
* AND DOING SO WILL BREAK YOUR WORLD
* This state indicates:
* 1. A minecraft auto-save is in-progress.
* 2. The backup is in-progress
* 3. Some chunks are writing to disk
* Guards on-disk world files during backups.
* <p>
* Chunk IO ({@code RegionFileStorage.write/flush}, running on the per-dimension IOWorker
* threads) holds the read lock; the backup worker holds the write lock while it snapshots
* files. The main thread never blocks on this lock: {@code autoSave} and manual
* {@code saveEverything} calls are skipped while the write lock is held (see
* {@code MixinMinecraftServer}), which is what makes it safe for the backup thread to
* wait on main-thread tasks while holding the write lock.
* <p>
* A {@link StampedLock} is used instead of a ReentrantReadWriteLock because the write
* stamp may be released from a different code path than the one that acquired it
* (early release once files are cloned to staging). None of the guarded sections are
* reentrant.
*/
public static final AtomicReference<IOState> BACKUP_LOCK = new AtomicReference<>(IOState.IDLE);
public static final StampedLock WORLD_IO_LOCK = new StampedLock();
public static final Gson GSON = new GsonBuilder().setPrettyPrinting()
.registerTypeAdapter(StorageOption.class, new StorageOptionSerializer(
Map.of("s3", StorageOption.S3.class,
@@ -1,8 +0,0 @@
package io.ib67.sfcraft;
public enum IOState {
SAVING_WORLD,
BACKUP,
STORAGE_SYNC,
IDLE
}
+155 -62
View File
@@ -4,6 +4,7 @@ import com.github.luben.zstd.Zstd;
import com.google.gson.reflect.TypeToken;
import io.ib67.sfcraft.config.JustBackupConfig;
import io.ib67.sfcraft.config.StorageOption;
import io.ib67.sfcraft.strategy.BackupChains;
import io.ib67.sfcraft.strategy.BackupStrategy;
import io.ib67.sfcraft.strategy.BackupTracker;
import io.ib67.sfcraft.strategy.LocalBackupStrategy;
@@ -25,14 +26,16 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class JustBackupMod implements ModInitializer {
public class JustBackupMod implements ModInitializer, FileWatcherThread.Listener {
public static final String MOD_ID = "justbackup";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
protected final Map<String, BackupSubject> backupSubjects = new HashMap<>();
@@ -44,6 +47,8 @@ public class JustBackupMod implements ModInitializer {
protected JustBackupConfig config;
protected BackupTracker tracker;
protected Path backupIndexPath;
protected Path tmpDir;
protected List<Path> excludedRoots;
protected FileWatcherThread watcherThread;
@Override
@@ -62,33 +67,45 @@ public class JustBackupMod implements ModInitializer {
@SneakyThrows
private void doInitialize() {
LOGGER.info("Loading backup configuration");
Path config1 = Path.of("config");
backupIndexPath = config1.resolve("just_backup_index.json");
var configPath = config1.resolve("just_backup.json");
Path configDir = Path.of("config");
backupIndexPath = configDir.resolve("just_backup_index.json");
var configPath = configDir.resolve("just_backup.json");
if (Files.notExists(configPath.getParent()))
Files.createDirectories(configPath.getParent());
if (Files.notExists(configPath))
saveConfig(configPath);
if (Files.notExists(backupIndexPath))
Files.createFile(backupIndexPath);
config = Globals.GSON.fromJson(Files.readString(configPath), JustBackupConfig.class);
config.backupSubjects().forEach((k, v) -> {
var subjectPath = Path.of(v);
if (Files.notExists(subjectPath)) {
LOGGER.error("Backup _subject {} does not exist.", v);
LOGGER.error("Backup subject {} does not exist.", v);
return;
}
backupSubjects.put(k, new BackupSubject(k, subjectPath, new ArrayList<>()));
backupSubjects.put(k, new BackupSubject(k, subjectPath));
});
if (config.zstdDictPath() != null && !config.zstdDictPath().isEmpty()) {
zstdDict = Files.readAllBytes(Path.of(config.zstdDictPath()));
}
tmpDir = Path.of(config.temporaryBackupDir());
excludedRoots = new ArrayList<>();
excludedRoots.add(tmpDir);
if (config.option() instanceof StorageOption.Local local) {
excludedRoots.add(Path.of(local.saveDir()));
}
var strategy = createStrategy();
this.tracker = new BackupTracker(strategy, Globals.GSON.fromJson(Files.readString(backupIndexPath), new TypeToken<>() {
}));
Map<String, Backup> initialIndex = Files.exists(backupIndexPath)
? Globals.GSON.fromJson(Files.readString(backupIndexPath), new TypeToken<Map<String, Backup>>() {
})
: null;
this.tracker = new BackupTracker(strategy, initialIndex, backupIndexPath);
LOGGER.info("Configuration has been loaded. Using storage option: {}", config.option().type());
scheduledBackupExecutor = Executors.newSingleThreadScheduledExecutor();
scheduledBackupExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
var t = new Thread(r, "JustBackup/Worker");
t.setDaemon(false);
return t;
});
registerWatchService();
registerHandlers();
}
@@ -98,7 +115,7 @@ public class JustBackupMod implements ModInitializer {
config.backupSubjects().entrySet().stream()
.map(it -> Map.entry(it.getKey(), Path.of(it.getValue())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)),
this::handleFileChanges
this
);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
if (watcherThread.getState() != Thread.State.TERMINATED) watcherThread.interrupt();
@@ -106,12 +123,16 @@ public class JustBackupMod implements ModInitializer {
watcherThread.start();
}
private void handleFileChanges(String _subject, Path path) {
synchronized (backupSubjects) {
var subject = backupSubjects.get(_subject);
if (subject != null)
subject.changedFiles().add(path); // check null
}
@Override
public void onChange(String subjectName, Path file) {
var subject = backupSubjects.get(subjectName);
if (subject != null) subject.addChanged(file);
}
@Override
public void onOverflow(String subjectName) {
var subject = backupSubjects.get(subjectName);
if (subject != null) subject.forceFullNext();
}
@SneakyThrows
@@ -130,96 +151,168 @@ public class JustBackupMod implements ModInitializer {
}
lastBackupServerTicks = server.getTickCount();
if (!suspend) backupAll(config.incremental()).thenAccept(it -> {
LOGGER.info("Backup success!");
it.forEach((k, v) -> LOGGER.info(k + ": " + v));
LOGGER.info("Scheduled backup finished.");
it.forEach((k, v) -> LOGGER.info("{}: {}", k, v));
});
},
config.backupIntervalMinutes(), config.backupIntervalMinutes(), TimeUnit.MINUTES);
});
ServerLifecycleEvents.SERVER_STOPPED.register(server -> {
try {
Files.deleteIfExists(Path.of(config.temporaryBackupDir()));
} catch (IOException e) {
throw new RuntimeException(e);
}
});
var command = new BackupCommands(this);
CommandRegistrationCallback.EVENT.register(command::registerCommand);
ServerLifecycleEvents.SERVER_STOPPED.register(server -> {
LOGGER.info("Shutting down the executor");
// Runs at the head of stopServer, before the final world save: in-flight backups must
// release the world IO lock before that save, or the flush would park forever.
ServerLifecycleEvents.SERVER_STOPPING.register(server -> {
suspend = true;
scheduledBackupExecutor.shutdown();
if (command.restoreIssued != null) {
command.restoreIssued.run();
try {
if (!scheduledBackupExecutor.awaitTermination(15, TimeUnit.SECONDS)) {
LOGGER.info("Waiting up to 10 minutes for the in-flight backup to finish...");
if (!scheduledBackupExecutor.awaitTermination(10, TimeUnit.MINUTES)) {
LOGGER.error("In-flight backup did not finish in time; its bundle may be incomplete.");
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
tracker.close();
});
ServerLifecycleEvents.SERVER_STOPPED.register(server -> {
if (command.restoreIssued != null) {
try {
command.restoreIssued.run();
} catch (Exception e) {
LOGGER.error("Backup restoration failed", e);
}
}
try {
tracker.close();
} catch (Exception e) {
LOGGER.error("Error while closing the backup storage", e);
}
cleanTemporaryDir();
if (watcherThread != null && watcherThread.getState() != Thread.State.TERMINATED) {
watcherThread.interrupt();
}
});
}
/**
* Removes only artifacts we created (staging clones, leftover bundles, temp downloads).
* Never deletes the directory itself: a misconfigured temporaryBackupDir pointing at the
* server root must not wipe the server.
*/
private void cleanTemporaryDir() {
try {
FsUtil.deleteRecursively(tmpDir.resolve("staging"));
} catch (IOException e) {
LOGGER.warn("Cannot clean staging dir", e);
}
if (Files.notExists(tmpDir)) return;
try (Stream<Path> files = Files.list(tmpDir)) {
files.filter(Files::isRegularFile)
.filter(p -> {
var n = p.getFileName().toString();
return (n.startsWith("backup_") && n.endsWith(".swb.zst")) || n.startsWith("s3_");
})
.forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
LOGGER.warn("Cannot delete leftover {}", p, e);
}
});
} catch (IOException e) {
LOGGER.warn("Cannot clean temporary backup dir", e);
}
}
/**
* Logs the message and, when enabled and a server is up, broadcasts it to players from
* the main thread (PlayerList is not safe to touch from the backup executor).
*/
public void broadcast(Component message) {
LOGGER.info(message.getString());
if (!config.broadcast()) return;
var s = server;
if (s == null || !s.isRunning()) return;
s.submit(() -> s.getPlayerList().broadcastSystemMessage(message, false));
}
public CompletableFuture<Map<String, Backup>> backupAll(boolean incremental) {
if (backupSubjects.isEmpty()) return CompletableFuture.completedFuture(Map.of());
var map = new HashMap<String, CompletableFuture<Backup>>();
server.getPlayerList().broadcastSystemMessage(
Component.literal(" BACKUP >> ").withColor(Color.RED.getRGB()).withStyle(it -> it.withBold(true))
.append(Component.nullToEmpty("The server is performing a backup, you may experience some lag.")),
false
);
broadcast(Component.literal(" BACKUP >> ").withColor(Color.RED.getRGB()).withStyle(it -> it.withBold(true))
.append(Component.nullToEmpty("The server is performing a backup, you may experience some lag.")));
for (var entry : backupSubjects.entrySet()) {
map.put(entry.getKey(), issueBackup(entry.getKey(), incremental));
}
return CompletableFuture.allOf(map.values().toArray(new CompletableFuture[0]))
.thenApply(it -> {
var _map = new HashMap<String, Backup>();
var results = new HashMap<String, Backup>();
map.forEach((k, v) -> {
var result = v.join();
LOGGER.info("Backup information of {}: {}", k, result);
_map.put(k, result);
results.put(k, result);
});
return _map;
return results;
});
}
public CompletableFuture<Backup> issueBackup(String subjectName, boolean incremental) {
var subject = backupSubjects.get(subjectName);
if (subject == null)
return CompletableFuture.failedFuture(new IllegalArgumentException("Unknown backup _subject: " + subjectName));
if (!incremental) {
try (var s = Files.walk(subject.root())) {
subject = new BackupSubject(subject.name(), subject.root(), s.filter(Files::isRegularFile)
.filter(it -> !it.getFileName().toString().equals("session.lock"))
.toList());
} catch (IOException e) {
return CompletableFuture.failedFuture(new IllegalArgumentException("Cannot perform full backup for " + subjectName, e));
}
}
var finalSubject = subject;
return CompletableFuture.failedFuture(new IllegalArgumentException("Unknown backup subject: " + subjectName));
if (config.allowGunzip()) LOGGER.info("Chunk reassembler is enabled!");
var worker = new BackupWorker(server, tracker, Path.of(config.temporaryBackupDir()),
subject, c ->
var worker = new BackupWorker(server, tracker, tmpDir, subject, incremental, excludedRoots, c ->
c.allowGunzip(config.allowGunzip())
.relativeRoot(finalSubject.root())
.allocator(ByteBufAllocator.DEFAULT)
.maxWorkers(config.compressionWorkerThreads())
.compressionLevel(config.compressionLevel())
.dictionary(zstdDict)
);
return CompletableFuture.supplyAsync(worker, scheduledBackupExecutor).whenComplete(this::onBackupComplete);
return CompletableFuture.supplyAsync(() -> {
if (!tracker.isAvailable()) {
throw new IllegalStateException("Backup storage is not available (quota exceeded?), skipping backup of " + subjectName);
}
return worker.get();
}, scheduledBackupExecutor).whenComplete(this::onBackupComplete);
}
@SneakyThrows
private void onBackupComplete(@UnknownNullability Backup backup, @UnknownNullability Throwable throwable) {
if (throwable != null) {
server.getPlayerList().broadcastSystemMessage(Component.literal("Backup failed. For administrators, please check your server console."), false);
LOGGER.error(throwable.getMessage(), throwable);
broadcast(Component.literal("Backup failed. For administrators, please check your server console."));
LOGGER.error("Backup failed", throwable);
} else {
Files.writeString(backupIndexPath, Globals.GSON.toJson(tracker.getTrackedBackups()));
server.getPlayerList().broadcastSystemMessage(Component.literal("Backup success. " + backup), false);
broadcast(Component.literal("Backup success. ").append(backup.toText()));
rotate(backup.subject());
}
}
/**
* Deletes whole generations (a full backup plus its incrementals) beyond the newest
* {@code keepBackups} ones. Entries without chain metadata are never rotated away.
*/
private void rotate(String subjectName) {
if (subjectName == null) return;
try {
var victims = BackupChains.rotationVictims(
tracker.getTrackedBackups().values(), subjectName, config.keepBackups());
for (var victim : victims) {
LOGGER.info("Rotating out old backup {} ({})", victim.backupKey(), victim.incremental() ? "INCR" : "FULL");
try {
tracker.deleteBackup(victim);
} catch (Exception e) {
LOGGER.warn("Cannot delete old backup {}", victim.backupKey(), e);
}
}
} catch (Exception e) {
LOGGER.warn("Backup rotation failed for {}", subjectName, e);
}
}
private BackupStrategy createStrategy() {
return switch (config.option()) {
case StorageOption.Local local -> new LocalBackupStrategy(local);
case StorageOption.S3 s3 -> new S3BackupStrategy(s3, Path.of(config.temporaryBackupDir()));
case StorageOption.S3 s3 -> new S3BackupStrategy(s3, tmpDir);
};
}
@@ -245,4 +338,4 @@ public class JustBackupMod implements ModInitializer {
}
Files.writeString(configPath, Globals.GSON.toJson(cfg));
}
}
}
@@ -0,0 +1,163 @@
package io.ib67.sfcraft;
import lombok.extern.log4j.Log4j2;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemoryLayout;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.StructLayout;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.VarHandle;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Clones files via the Linux {@code FICLONE} ioctl (reflink). On CoW filesystems
* (btrfs, XFS with reflink, OpenZFS >= 2.2) this is a metadata-only operation: the clone
* shares extents with the source, so staging a whole backup takes milliseconds and no
* extra disk space, letting the backup release the world IO lock before compressing.
* <p>
* Unsupported setups (non-Linux, ext4, overlayfs, staging dir on a different mount) are
* detected on first use and cached per subject; callers then fall back to packing while
* holding the world IO lock.
*/
@Log4j2
public final class ReflinkCloner {
private static final long FICLONE = 0x40049409L;
private static final int O_RDONLY = 0;
private static final int O_WRONLY = 1;
private static final int O_CREAT = 0x40;
private static final int O_TRUNC = 0x200;
private static final int ENOENT = 2;
private static final int EXDEV = 18;
private static final MethodHandle OPEN;
private static final MethodHandle IOCTL;
private static final MethodHandle CLOSE;
private static final StructLayout CAPTURE_LAYOUT;
private static final VarHandle ERRNO;
private static final boolean AVAILABLE;
private static final Set<String> UNSUPPORTED_SUBJECTS = ConcurrentHashMap.newKeySet();
static {
MethodHandle open = null, ioctl = null, close = null;
StructLayout capture = null;
VarHandle errno = null;
boolean ok = false;
try {
if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("linux")) {
var linker = Linker.nativeLinker();
var lookup = linker.defaultLookup();
var captureErrno = Linker.Option.captureCallState("errno");
capture = Linker.Option.captureStateLayout();
errno = capture.varHandle(MemoryLayout.PathElement.groupElement("errno"));
open = linker.downcallHandle(lookup.findOrThrow("open"),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT),
captureErrno);
ioctl = linker.downcallHandle(lookup.findOrThrow("ioctl"),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT),
captureErrno, Linker.Option.firstVariadicArg(2));
close = linker.downcallHandle(lookup.findOrThrow("close"),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
ok = true;
} else {
log.info("Reflink fast path is Linux-only; backups will pack while holding the world IO lock.");
}
} catch (Throwable t) {
log.info("Reflink fast path unavailable ({}); backups will pack while holding the world IO lock.", t.toString());
}
OPEN = open;
IOCTL = ioctl;
CLOSE = close;
CAPTURE_LAYOUT = capture;
ERRNO = errno;
AVAILABLE = ok;
}
private ReflinkCloner() {
}
/**
* Clones {@code files} (which must live under {@code subjectRoot}) into
* {@code stagingRoot}, preserving their relative structure. Files that vanished since
* being listed are skipped.
*
* @return the staging root on success, or null if reflink is unsupported here — in that
* case any partial staging has been cleaned up and the subject is remembered as
* unsupported so future backups skip the attempt.
*/
public static Path tryStage(String subjectName, Path subjectRoot, List<Path> files, Path stagingRoot) {
if (!AVAILABLE || UNSUPPORTED_SUBJECTS.contains(subjectName)) return null;
var absRoot = subjectRoot.toAbsolutePath().normalize();
try {
Files.createDirectories(stagingRoot);
for (Path file : files) {
var src = file.toAbsolutePath().normalize();
if (!Files.isRegularFile(src)) continue;
var dst = stagingRoot.resolve(absRoot.relativize(src));
Files.createDirectories(dst.getParent());
int err = clone(src, dst);
if (err == ENOENT) continue; // deleted between listing and cloning
if (err != 0) {
markUnsupported(subjectName, src, err);
FsUtil.deleteRecursively(stagingRoot);
return null;
}
}
return stagingRoot;
} catch (Throwable t) {
log.warn("Reflink staging failed; falling back to packing under the world IO lock.", t);
UNSUPPORTED_SUBJECTS.add(subjectName);
try {
FsUtil.deleteRecursively(stagingRoot);
} catch (Exception cleanup) {
log.warn("Cannot clean up staging dir {}", stagingRoot, cleanup);
}
return null;
}
}
private static void markUnsupported(String subjectName, Path src, int err) {
UNSUPPORTED_SUBJECTS.add(subjectName);
if (err == EXDEV) {
log.warn("Reflink not possible for subject {}: {} and the temporary backup dir are on " +
"different filesystems. Put temporaryBackupDir on the same filesystem as the " +
"backup subject to enable the CoW fast path. Falling back to packing under the world IO lock.", subjectName, src);
} else {
log.info("Reflink unsupported for subject {} (errno {} on {}); " +
"falling back to packing under the world IO lock.", subjectName, err, src);
}
}
private static int clone(Path src, Path dst) throws Throwable {
try (var arena = Arena.ofConfined()) {
var capture = arena.allocate(CAPTURE_LAYOUT);
int srcFd = (int) OPEN.invokeExact(capture, arena.allocateFrom(src.toString()), O_RDONLY, 0);
if (srcFd < 0) return errno(capture);
try {
int dstFd = (int) OPEN.invokeExact(capture, arena.allocateFrom(dst.toString()),
O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (dstFd < 0) return errno(capture);
try {
int result = (int) IOCTL.invokeExact(capture, dstFd, FICLONE, srcFd);
return result == 0 ? 0 : errno(capture);
} finally {
var ignored = (int) CLOSE.invokeExact(dstFd);
}
} finally {
var ignored = (int) CLOSE.invokeExact(srcFd);
}
}
}
private static int errno(MemorySegment capture) {
return (int) ERRNO.get(capture, 0L);
}
}
@@ -1,14 +1,22 @@
package io.ib67.sfcraft.mixin;
import io.ib67.sfcraft.Globals;
import io.ib67.sfcraft.IOState;
import io.ib67.sfcraft.JustBackupMod;
import net.minecraft.server.MinecraftServer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* Skips main-thread saves while a backup holds the write lock. The main thread must never
* block on {@link Globals#WORLD_IO_LOCK}: a save request would join the IOWorkers, which
* are parked on the read lock, wedging the main thread until the backup finishes (and
* tripping the watchdog). The world was saved right before the backup acquired the lock,
* so skipping loses nothing.
*/
@Mixin(MinecraftServer.class)
public abstract class MixinMinecraftServer {
@@ -19,23 +27,21 @@ public abstract class MixinMinecraftServer {
protected abstract int computeNextAutosaveInterval();
@Inject(method = "autoSave", at = @At("HEAD"), cancellable = true)
private void backup$checkSaving(CallbackInfo ci) {
// is already backing up
this.ticksUntilAutosave = computeNextAutosaveInterval();
IOState witness;
while ((witness = Globals.BACKUP_LOCK.compareAndExchange(IOState.IDLE, IOState.SAVING_WORLD)) == IOState.STORAGE_SYNC) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.err.println("Error while waiting for chunk sync. " + e);
ci.cancel();
}
private void backup$skipAutoSaveDuringBackup(CallbackInfo ci) {
if (Globals.WORLD_IO_LOCK.isWriteLocked()) {
// vanilla resets this at the head of autoSave; replicate it on the cancel path
// so the autosave doesn't re-trigger every tick
this.ticksUntilAutosave = computeNextAutosaveInterval();
JustBackupMod.LOGGER.info("Backup in progress, skipping this auto-save.");
ci.cancel();
}
if (witness == IOState.BACKUP) ci.cancel();
}
@Inject(method = "autoSave", at = @At("RETURN"))
private void backup$disableProtect(CallbackInfo ci) {
Globals.BACKUP_LOCK.setRelease(IOState.IDLE);
@Inject(method = "saveEverything", at = @At("HEAD"), cancellable = true)
private void backup$skipManualSaveDuringBackup(boolean suppressLog, boolean flush, boolean force, CallbackInfoReturnable<Boolean> cir) {
if (Globals.WORLD_IO_LOCK.isWriteLocked()) {
JustBackupMod.LOGGER.warn("Backup in progress, skipping this save request. The world was already saved when the backup started.");
cir.setReturnValue(false);
}
}
}
@@ -1,62 +1,38 @@
package io.ib67.sfcraft.mixin;
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import io.ib67.sfcraft.Globals;
import io.ib67.sfcraft.IOState;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import lombok.SneakyThrows;
import net.minecraft.world.level.chunk.storage.RegionFile;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.storage.RegionFileStorage;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Overwrite;
import org.spongepowered.asm.mixin.Shadow;
/**
* Holds the read side of {@link Globals#WORLD_IO_LOCK} around region file writes and flushes.
* Multiple IOWorker threads (one per dimension and storage kind) may run concurrently under
* the read lock; while the backup worker holds the write lock, all region IO parks here and
* resumes untouched afterwards.
*/
@Mixin(RegionFileStorage.class)
public abstract class MixinRegionBasedStorage {
@Shadow
@Final
private Long2ObjectLinkedOpenHashMap<RegionFile> regionCache;
/**
* @author iceBear67
* @reason to wrap the saving process with an exception handler.
*/
@Overwrite
@SneakyThrows
public void flush() {
// allowed states:
// STORAGE_SYNC, AUTO_SAVE, IDLE
// we try
var witness = Globals.BACKUP_LOCK.getAndSet(IOState.STORAGE_SYNC);
if (witness == IOState.BACKUP) {
// yield out and keep blocking.
// The backup worker will wait for us setting this back to backup.
int i =0;
Globals.BACKUP_LOCK.set(IOState.BACKUP);
while (Globals.BACKUP_LOCK.getAcquire() == IOState.BACKUP) {
if(i==0){
System.out.println("Server is performing backup, ChunkIO will wait for it.");
}
if(i++ > 3000){
System.err.println("WARNING: BACKUP_LOCK is holding for 10 minutes!");
System.err.println("ChunkIO will not wait for backup anymore, writting to disk...");
System.err.println("You may adjust this behaviour in configuration");
break;
}
Thread.sleep(2000);
}
}
// program point: IDLE, AUTOSAVE, STORAGESYNC
@WrapMethod(method = "write")
private void backup$guardWrite(ChunkPos pos, CompoundTag tag, Operation<Void> original) {
long stamp = Globals.WORLD_IO_LOCK.readLock();
try {
for (RegionFile value : this.regionCache.values()) {
value.flush();
}
original.call(pos, tag);
} finally {
// we assert that there are no other threads calling sync()
// so we can just jump back to the witness value.
// witness: AUTO_SAVE or IDLE
Globals.BACKUP_LOCK.compareAndExchange(IOState.STORAGE_SYNC, witness);
Globals.BACKUP_LOCK.setRelease(witness);
Globals.WORLD_IO_LOCK.unlockRead(stamp);
}
}
@WrapMethod(method = "flush")
private void backup$guardFlush(Operation<Void> original) {
long stamp = Globals.WORLD_IO_LOCK.readLock();
try {
original.call();
} finally {
Globals.WORLD_IO_LOCK.unlockRead(stamp);
}
}
}
@@ -0,0 +1,84 @@
package io.ib67.sfcraft.strategy;
import io.ib67.sfcraft.Backup;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
/**
* Pure functions over the backup index: resolving restore chains (full base + following
* incrementals) and grouping backups into generations for rotation. Legacy entries
* (created before subject/createdAt existed) are excluded from both — they are restorable
* standalone but never chained or rotated away.
*/
public final class BackupChains {
private BackupChains() {
}
/**
* Resolves the list of bundles that must be extracted, in order, to restore {@code target}.
* A full backup restores alone; an incremental needs its most recent full base and every
* incremental in between.
*
* @throws IllegalStateException if the target is an incremental without a reachable base
*/
public static List<Backup> resolveChain(Collection<Backup> tracked, Backup target) {
if (!target.incremental()) return List.of(target);
if (target.legacy()) {
throw new IllegalStateException("Backup " + target.backupKey()
+ " lacks chain metadata (created by an older version) and cannot be restored incrementally.");
}
var chain = new ArrayList<Backup>();
tracked.stream()
.filter(b -> !b.legacy() && Objects.equals(target.subject(), b.subject()))
.filter(b -> b.createdAt() < target.createdAt())
.sorted(Comparator.comparingLong(Backup::createdAt))
.forEach(b -> {
if (!b.incremental()) chain.clear(); // a newer full base supersedes older history
chain.add(b);
});
chain.add(target);
if (chain.getFirst().incremental()) {
throw new IllegalStateException("No full backup found before " + target.backupKey()
+ "; cannot restore an incremental backup without its full base.");
}
return List.copyOf(chain);
}
/**
* Groups a subject's backups into generations: each full backup starts a new generation
* that collects the incrementals after it. Incrementals older than the first full backup
* (orphans) belong to no generation.
*/
public static List<List<Backup>> generations(Collection<Backup> tracked, String subject) {
var result = new ArrayList<List<Backup>>();
List<Backup> current = null;
var sorted = tracked.stream()
.filter(b -> !b.legacy() && Objects.equals(subject, b.subject()))
.sorted(Comparator.comparingLong(Backup::createdAt))
.toList();
for (var b : sorted) {
if (!b.incremental()) {
current = new ArrayList<>();
result.add(current);
}
if (current != null) current.add(b);
}
return result;
}
/**
* @return the backups that fall out of the newest {@code keepGenerations} generations and
* should be deleted. Whole generations only — an incremental is never separated from its base.
*/
public static List<Backup> rotationVictims(Collection<Backup> tracked, String subject, int keepGenerations) {
var generations = generations(tracked, subject);
if (generations.size() <= keepGenerations) return List.of();
return generations.subList(0, generations.size() - keepGenerations).stream()
.flatMap(List::stream)
.toList();
}
}
@@ -6,10 +6,16 @@ import java.nio.file.Path;
public interface BackupStrategy {
/**
* create a backup
* @return a key to this backup
* Stores the finished bundle.
*
* @param subject name of the backup subject
* @param source directory the bundle was created from
* @param bundle the bundle file to store; the caller deletes it afterwards
* @param incremental whether the bundle only contains changed files
* @param createdAt epoch millis the backup was taken at
* @return metadata describing the stored backup
*/
Backup createBackup(String subject, Path source, Path bundle);
Backup createBackup(String subject, Path source, Path bundle, boolean incremental, long createdAt);
void recoverBackup(Backup backup, Path restorePath);
@@ -1,28 +1,46 @@
package io.ib67.sfcraft.strategy;
import io.ib67.sfcraft.Backup;
import io.ib67.sfcraft.Globals;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.nio.file.StandardCopyOption;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* Wraps a storage strategy with an in-memory index of known backups, persisted atomically
* to {@code indexPath} after every mutation. The map is concurrent: the backup executor
* writes while the main thread reads it for commands and tab completion.
*/
@Log4j2
public class BackupTracker implements BackupStrategy {
protected final BackupStrategy underlying;
@Getter
protected final Map<String, Backup> trackedBackups;
protected final Map<String, Backup> trackedBackups = new ConcurrentHashMap<>();
protected final Path indexPath;
public BackupTracker(BackupStrategy underlying, Map<String, Backup> trackedBackups) {
public BackupTracker(BackupStrategy underlying, Map<String, Backup> initial, Path indexPath) {
this.underlying = Objects.requireNonNull(underlying);
this.trackedBackups = trackedBackups == null ? new HashMap<>() : trackedBackups;
this.indexPath = Objects.requireNonNull(indexPath);
if (initial != null) {
initial.forEach((k, v) -> {
if (k != null && v != null) trackedBackups.put(k, v);
});
}
}
@Override
public Backup createBackup(String subject, Path from, Path pathToBundle) {
var result = underlying.createBackup(subject, from, pathToBundle);
public Backup createBackup(String subject, Path source, Path bundle, boolean incremental, long createdAt) {
var result = underlying.createBackup(subject, source, bundle, incremental, createdAt);
trackedBackups.put(result.backupKey(), result);
persist();
return result;
}
@@ -35,6 +53,26 @@ public class BackupTracker implements BackupStrategy {
public void deleteBackup(Backup backup) {
underlying.deleteBackup(backup);
trackedBackups.remove(backup.backupKey());
persist();
}
/**
* Writes the index to a temp file first and atomically moves it into place, so a crash
* mid-write can never leave a corrupt index behind (which would disable the mod on the
* next startup).
*/
protected synchronized void persist() {
try {
var tmp = indexPath.resolveSibling(indexPath.getFileName() + ".tmp");
Files.writeString(tmp, Globals.GSON.toJson(trackedBackups));
try {
Files.move(tmp, indexPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, indexPath, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
log.error("Cannot persist backup index to {}", indexPath, e);
}
}
@Override
@@ -25,24 +25,23 @@ public class LocalBackupStrategy implements BackupStrategy {
@Override
@SneakyThrows
public Backup createBackup(String subject, Path from, Path pathToBundle) {
public Backup createBackup(String subject, Path source, Path bundle, boolean incremental, long createdAt) {
var parent = backupParentRoot.resolve(subject);
var fileName = bundle.getFileName().toString();
var result = new Backup(
pathToBundle.getFileName().toString(),
Path.of(subject).resolve(pathToBundle.getFileName()).normalize().toString(),
"local", from.toString(), false, Files.size(pathToBundle));
if(Files.notExists(parent)) {
fileName,
Path.of(subject).resolve(fileName).normalize().toString(),
"local", source.toString(), subject, incremental, createdAt, Files.size(bundle));
if (Files.notExists(parent)) {
Files.createDirectories(parent);
}
//todo new name
Files.move(pathToBundle, parent.resolve(pathToBundle.getFileName()));
Files.move(bundle, parent.resolve(fileName));
return result;
}
@Override
@SneakyThrows
public void recoverBackup(Backup backup, Path restorePath) {
// check if it is compressed
var backupFile = backupParentRoot.resolve(backup.backupKey());
if (Files.notExists(backupFile)) throw new IllegalArgumentException("Backup " + backupFile + " does not exist");
BundleReader.builder().build().extract(backupFile, restorePath);
@@ -51,7 +50,7 @@ public class LocalBackupStrategy implements BackupStrategy {
@SneakyThrows
@Override
public void deleteBackup(Backup backup) {
Validate.isTrue("local".equals(backup.backupKey()));
Validate.isTrue("local".equals(backup.type()), "Not a local backup: %s", backup.backupKey());
var bundlePath = backupParentRoot.resolve(backup.backupKey());
Files.deleteIfExists(bundlePath);
}
@@ -61,14 +60,14 @@ public class LocalBackupStrategy implements BackupStrategy {
public boolean isAvailable() {
if (option.diskSizeQuotaBytes() == 0) return true;
try (var f = Files.walk(backupParentRoot)) {
if (f.mapToLong(it->{
if (f.filter(Files::isRegularFile).mapToLong(it -> {
try {
return Files.size(it);
} catch (IOException e) {
throw new RuntimeException(e);
}
}).sum() >= option.diskSizeQuotaBytes()) {
log.warn("Have no space to set another backup! ");
log.warn("Backup disk quota ({} bytes) exceeded, refusing to create new backups!", option.diskSizeQuotaBytes());
return false;
}
}
@@ -7,20 +7,18 @@ import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
@Log4j2
public class S3BackupStrategy implements BackupStrategy {
protected final AtomicBoolean uploading = new AtomicBoolean(false);
protected final StorageOption.S3 option;
protected final S3Client s3;
protected final Path temporaryDownloadPath;
@@ -45,45 +43,42 @@ public class S3BackupStrategy implements BackupStrategy {
@Override
@SneakyThrows
public Backup createBackup(String subject, Path from, Path pathToBundle) {
if (!uploading.compareAndSet(false, true)) {
throw new IllegalStateException("Last upload is not done yet! keep waiting...");
}
int i = 0;
do {
public Backup createBackup(String subject, Path source, Path bundle, boolean incremental, long createdAt) {
// bundle names embed the creation timestamp, so keys are unique and retries idempotent
var object = option.prefix() + "/" + bundle.getFileName();
int attempt = 0;
while (true) {
try {
var object = option.prefix() + "/" + pathToBundle.getFileName().toString();
s3.putObject(o -> o.bucket(option.bucket())
.contentType("application/octet-stream")
.ifNoneMatch("*") // to avoid content overriding
.key(object).build(), pathToBundle);
return new Backup(pathToBundle.getFileName().toString(),
.key(object), bundle);
return new Backup(bundle.getFileName().toString(),
object, "s3",
from.toString(),
false,
Files.size(pathToBundle));
} catch (IOException e) {
if (i >= option.retryAmount()) {
throw new IllegalStateException("Cannot upload " + pathToBundle + " to S3");
source.toString(),
subject, incremental, createdAt,
Files.size(bundle));
} catch (SdkException e) {
if (attempt >= option.retryAmount()) {
throw new IllegalStateException("Cannot upload " + bundle + " to S3 after "
+ attempt + " retries", e);
}
var nextTry = Math.pow(2, i++);
log.error("Error uploading S3. trying in {}s later. ({}/{})", nextTry, i, option.retryAmount(), e);
Thread.sleep((long) (nextTry * 1000));
} catch (Exception e) {
throw new IllegalStateException("Unrecoverable exception occurred when uploading " + pathToBundle, e);
} finally {
uploading.set(false);
var backoffSeconds = (long) Math.pow(2, attempt++);
log.error("Error uploading to S3, retrying in {}s ({}/{})",
backoffSeconds, attempt, option.retryAmount(), e);
Thread.sleep(backoffSeconds * 1000);
}
} while (true);
}
}
@Override
@SneakyThrows
public void recoverBackup(Backup backup, Path restorePath) {
var resp = s3.getObject(b -> b.bucket(option.bucket()).key(backup.backupKey()));
var target = temporaryDownloadPath.resolve("s3_" + System.currentTimeMillis());
try (var fs = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
resp.transferTo(fs);
try {
try (var resp = s3.getObject(b -> b.bucket(option.bucket()).key(backup.backupKey()));
var fs = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
resp.transferTo(fs);
}
BundleReader.builder().build().extract(target, restorePath);
} finally {
Files.deleteIfExists(target);
@@ -98,10 +93,6 @@ public class S3BackupStrategy implements BackupStrategy {
@Override
public boolean isAvailable() {
if (uploading.get()) {
log.warn("Last S3 Backup is still uploading!");
return false;
}
return true;
}
@@ -0,0 +1,88 @@
package io.ib67.sfcraft;
import io.ib67.sfcraft.strategy.BackupChains;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class TestBackupChains {
private static Backup full(String key, String subject, long at) {
return new Backup(key, key, "local", "world", subject, false, at, 1);
}
private static Backup incr(String key, String subject, long at) {
return new Backup(key, key, "local", "world", subject, true, at, 1);
}
private static Backup legacy(String key) {
return new Backup(key, key, "local", "world", null, false, 0, 1);
}
@Test
public void fullBackupRestoresAlone() {
var full = full("f1", "world", 100);
var all = List.of(full, incr("i1", "world", 200));
assertEquals(List.of(full), BackupChains.resolveChain(all, full));
}
@Test
public void incrementalChainsBackToItsBase() {
var f1 = full("f1", "world", 100);
var i1a = incr("i1a", "world", 200);
var i1b = incr("i1b", "world", 300);
var f2 = full("f2", "world", 400);
var i2a = incr("i2a", "world", 500);
var all = List.of(i2a, f1, i1b, f2, i1a); // deliberately unordered
assertEquals(List.of(f1, i1a, i1b), BackupChains.resolveChain(all, i1b));
assertEquals(List.of(f2, i2a), BackupChains.resolveChain(all, i2a));
}
@Test
public void chainIgnoresOtherSubjects() {
var f1 = full("f1", "world", 100);
var otherFull = full("fo", "nether", 150);
var i1 = incr("i1", "world", 200);
assertEquals(List.of(f1, i1), BackupChains.resolveChain(List.of(f1, otherFull, i1), i1));
}
@Test
public void orphanIncrementalIsRejected() {
var i1 = incr("i1", "world", 200);
assertThrows(IllegalStateException.class,
() -> BackupChains.resolveChain(List.of(i1), i1));
}
@Test
public void legacyEntryRestoresStandalone() {
var old = legacy("old");
assertEquals(List.of(old), BackupChains.resolveChain(List.of(old), old));
}
@Test
public void rotationKeepsWholeGenerations() {
var f1 = full("f1", "world", 100);
var i1 = incr("i1", "world", 200);
var f2 = full("f2", "world", 300);
var i2 = incr("i2", "world", 400);
var f3 = full("f3", "world", 500);
var all = List.of(f1, i1, f2, i2, f3);
assertEquals(List.of(), BackupChains.rotationVictims(all, "world", 3));
assertEquals(List.of(f1, i1), BackupChains.rotationVictims(all, "world", 2));
assertEquals(List.of(f1, i1, f2, i2), BackupChains.rotationVictims(all, "world", 1));
}
@Test
public void rotationNeverTouchesLegacyOrOrphans() {
var orphan = incr("orphan", "world", 50);
var old = legacy("old");
var f1 = full("f1", "world", 100);
var f2 = full("f2", "world", 200);
var all = List.of(orphan, old, f1, f2);
assertEquals(List.of(f1), BackupChains.rotationVictims(all, "world", 1));
}
}