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>
43 lines
1.3 KiB
Java
43 lines
1.3 KiB
Java
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
|
|
) {
|
|
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());
|
|
}
|
|
}
|