Implement multi-subject and incremental backup
This commit is contained in:
1 parent
82ae81c7af
commit
641fe1ac0a
13 files changed
+249
-117
No files matched your search
@@ -152,7 +152,7 @@ public class BundleWriter implements Closeable {
|
|||||||
|
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
private void writeReassembleInMem(Path path) {
|
private void writeReassembleInMem(Path path) {
|
||||||
if (!"region".equals(path.getParent().toString()) && !path.toString().endsWith(".mca")) {
|
if (!"region".equals(path.getParent().toString()) && !path.toString().endsWith(".mca")) { // todo use pattern matcher
|
||||||
if(verbose) System.out.println("Mismatch " + path + ", parent: " + path.getParent());
|
if(verbose) System.out.println("Mismatch " + path + ", parent: " + path.getParent());
|
||||||
writePlain(path);
|
writePlain(path);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import com.mojang.brigadier.arguments.StringArgumentType;
|
|||||||
import com.mojang.brigadier.context.CommandContext;
|
import com.mojang.brigadier.context.CommandContext;
|
||||||
import com.mojang.brigadier.suggestion.Suggestions;
|
import com.mojang.brigadier.suggestion.Suggestions;
|
||||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
|
||||||
import net.minecraft.command.CommandRegistryAccess;
|
import net.minecraft.command.CommandRegistryAccess;
|
||||||
import net.minecraft.server.command.CommandManager;
|
import net.minecraft.server.command.CommandManager;
|
||||||
import net.minecraft.server.command.ServerCommandSource;
|
import net.minecraft.server.command.ServerCommandSource;
|
||||||
@@ -55,23 +54,6 @@ public class BackupCommands {
|
|||||||
return Command.SINGLE_SUCCESS;
|
return Command.SINGLE_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int cmdIssueBackup(CommandContext<ServerCommandSource> ctx) {
|
|
||||||
var s = ctx.getSource();
|
|
||||||
if (JustBackupMod.PERFORMING_BACKUP.get()) {
|
|
||||||
s.sendMessage(Text.of("You can't issue a backup because another backup is in progress."));
|
|
||||||
return Command.SINGLE_SUCCESS;
|
|
||||||
}
|
|
||||||
mod.issueBackup()
|
|
||||||
.whenComplete((backup, error) -> {
|
|
||||||
if (error != null) {
|
|
||||||
s.sendMessage(Text.of("Cannot backup, error: " + error.getMessage()));
|
|
||||||
} else {
|
|
||||||
s.sendMessage(Text.of("Backup created successfully! name: " + backup.name()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Command.SINGLE_SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int cmdSuspend(CommandContext<ServerCommandSource> ctx) {
|
public int cmdSuspend(CommandContext<ServerCommandSource> ctx) {
|
||||||
mod.suspend = !mod.suspend;
|
mod.suspend = !mod.suspend;
|
||||||
ctx.getSource().sendMessage(Text.of("Backup disabled: " + mod.suspend));
|
ctx.getSource().sendMessage(Text.of("Backup disabled: " + mod.suspend));
|
||||||
@@ -148,7 +130,7 @@ public class BackupCommands {
|
|||||||
.then(argument("backupName", StringArgumentType.greedyString())
|
.then(argument("backupName", StringArgumentType.greedyString())
|
||||||
.suggests(this::suggestBackups)
|
.suggests(this::suggestBackups)
|
||||||
.executes(this::cmdRestore)))
|
.executes(this::cmdRestore)))
|
||||||
.then(literal("create").executes(this::cmdIssueBackup))
|
// .then(literal("create").executes(this::cmdIssueBackupFull)) todo
|
||||||
.then(literal("suspend").executes(this::cmdSuspend))
|
.then(literal("suspend").executes(this::cmdSuspend))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package io.ib67.sfcraft;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record BackupSubject(
|
||||||
|
String name,
|
||||||
|
Path root,
|
||||||
|
List<Path> changedFiles
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package io.ib67.sfcraft;
|
package io.ib67.sfcraft;
|
||||||
|
|
||||||
import io.ib67.sfcraft.bundler.BundleWriter;
|
import io.ib67.sfcraft.bundler.BundleWriter;
|
||||||
import io.ib67.sfcraft.config.JustBackupConfig;
|
|
||||||
import io.ib67.sfcraft.mixin.LevelStorageAccessor;
|
|
||||||
import io.ib67.sfcraft.strategy.BackupStrategy;
|
import io.ib67.sfcraft.strategy.BackupStrategy;
|
||||||
import lombok.SneakyThrows;
|
import lombok.SneakyThrows;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
@@ -10,20 +8,25 @@ import net.minecraft.server.MinecraftServer;
|
|||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
|
||||||
import static io.ib67.sfcraft.JustBackupMod.BACKUP_LOCK;
|
import static io.ib67.sfcraft.Globals.BACKUP_LOCK;
|
||||||
|
|
||||||
@Log4j2
|
@Log4j2
|
||||||
public record BackupWorker(
|
public record BackupWorker(
|
||||||
MinecraftServer server,
|
MinecraftServer server,
|
||||||
BackupStrategy strategy,
|
BackupStrategy strategy,
|
||||||
JustBackupConfig config
|
Path tmpDir,
|
||||||
|
BackupSubject subject,
|
||||||
|
UnaryOperator<BundleWriter.BundleWriterBuilder> config
|
||||||
) implements Supplier<Backup> {
|
) implements Supplier<Backup> {
|
||||||
|
|
||||||
|
@SneakyThrows
|
||||||
|
public BackupWorker {
|
||||||
|
if (Files.notExists(tmpDir)) Files.createDirectories(tmpDir);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
public Backup get() {
|
public Backup get() {
|
||||||
@@ -37,29 +40,17 @@ public record BackupWorker(
|
|||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
}
|
}
|
||||||
// performing backup is now true
|
// performing backup is now true
|
||||||
var backupName = "Backup_" + DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now());
|
var tmpBundle = tmpDir.resolve("backup_" + subject + "_" + System.currentTimeMillis() + ".swb.zst");
|
||||||
var backupParentRoot = Path.of(config.temporaryBackupDir());
|
|
||||||
if (Files.notExists(backupParentRoot)) Files.createDirectories(backupParentRoot);
|
|
||||||
var backupFile = backupParentRoot.resolve(backupName + ".jbp.zst");
|
|
||||||
try {
|
try {
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
// not saved yet.
|
// not saved yet.
|
||||||
server.saveAll(true, true, false);
|
server.saveAll(true, true, false);
|
||||||
}
|
}
|
||||||
var session = ((LevelStorageAccessor) server).getSession();
|
// tmp file path
|
||||||
var path = session.getDirectory().getRootPath();
|
log.info("Creating bundle {}", tmpBundle);
|
||||||
log.info("Creating bundle {}", backupName);
|
BundleWriter.createBundle(tmpBundle, subject.changedFiles(), config);
|
||||||
//todo diff
|
return strategy.createBackup(subject.name(), subject.root(), tmpBundle);
|
||||||
Path worldDir = Path.of(path);
|
|
||||||
var tree = new WorldDir(worldDir);
|
|
||||||
BundleWriter.createBundle(backupFile, tree.everything(), c -> c.allowGunzip(config.allowGunzip())
|
|
||||||
.relativeRoot(worldDir)
|
|
||||||
.compressionLevel(config.compressionLevel()));
|
|
||||||
var backup = strategy.createBackup(worldDir, backupFile);
|
|
||||||
Files.deleteIfExists(backupFile);
|
|
||||||
return backup;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Files.deleteIfExists(backupFile);
|
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
// Though we won't access the state within backup progress, there are certain cases
|
// Though we won't access the state within backup progress, there are certain cases
|
||||||
@@ -69,6 +60,7 @@ public record BackupWorker(
|
|||||||
log.error("Expect BACKUP state but got {}", BACKUP_LOCK.get());
|
log.error("Expect BACKUP state but got {}", BACKUP_LOCK.get());
|
||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
}
|
}
|
||||||
|
Files.deleteIfExists(tmpBundle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package io.ib67.sfcraft;
|
||||||
|
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
|
||||||
|
import java.nio.file.*;
|
||||||
|
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;
|
||||||
|
|
||||||
|
public class FileWatcherThread extends Thread {
|
||||||
|
protected final Map<Path, String> watchSubject;
|
||||||
|
protected final BiConsumer<String, Path> eventListener;
|
||||||
|
|
||||||
|
public FileWatcherThread(
|
||||||
|
Map<String, Path> watchSubject,
|
||||||
|
BiConsumer<String, Path> eventListener
|
||||||
|
) {
|
||||||
|
this.watchSubject = watchSubject.entrySet()
|
||||||
|
.stream()
|
||||||
|
.flatMap(it -> walkThrough(it.getValue()).map(r -> Map.entry(r, it.getKey())))
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
Map.Entry::getKey,
|
||||||
|
Map.Entry::getValue
|
||||||
|
));
|
||||||
|
this.eventListener = eventListener;
|
||||||
|
setName("JustBackup/FileWatcher");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SneakyThrows
|
||||||
|
private static Stream<Path> walkThrough(Path path) {
|
||||||
|
return Files.walk(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SneakyThrows
|
||||||
|
public void run() {
|
||||||
|
try (var watcher = FileSystems.getDefault().newWatchService()) {
|
||||||
|
for (Path value : watchSubject.keySet()) {
|
||||||
|
value.register(
|
||||||
|
watcher,
|
||||||
|
StandardWatchEventKinds.ENTRY_CREATE,
|
||||||
|
StandardWatchEventKinds.ENTRY_MODIFY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (; ; ) {
|
||||||
|
var key = watcher.poll(10, TimeUnit.SECONDS);
|
||||||
|
if (key == null) continue;
|
||||||
|
if (Thread.interrupted()) return;
|
||||||
|
for (var ev : key.pollEvents()) {
|
||||||
|
var kind = ev.kind();
|
||||||
|
if (kind == StandardWatchEventKinds.OVERFLOW)
|
||||||
|
continue;
|
||||||
|
var path = ((WatchEvent<Path>) ev).context();
|
||||||
|
var subject = Objects.requireNonNull(watchSubject.get(path.getParent()));
|
||||||
|
if (Files.isRegularFile(path)) {
|
||||||
|
eventListener.accept(subject, path.getParent());
|
||||||
|
} else if (Files.isDirectory(path) && kind == StandardWatchEventKinds.ENTRY_CREATE) {
|
||||||
|
path.register(watcher, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY);
|
||||||
|
watchSubject.put(path, subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package io.ib67.sfcraft;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import io.ib67.sfcraft.config.StorageOption;
|
||||||
|
import io.ib67.sfcraft.config.serializer.StorageOptionSerializer;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
public static final AtomicReference<IOState> BACKUP_LOCK = new AtomicReference<>(IOState.IDLE);
|
||||||
|
public static final Gson GSON = new GsonBuilder().setPrettyPrinting()
|
||||||
|
.registerTypeAdapter(StorageOption.class, new StorageOptionSerializer(
|
||||||
|
Map.of("s3", StorageOption.S3.class,
|
||||||
|
"local", StorageOption.Local.class)
|
||||||
|
))
|
||||||
|
.create();
|
||||||
|
}
|
||||||
@@ -1,79 +1,47 @@
|
|||||||
package io.ib67.sfcraft;
|
package io.ib67.sfcraft;
|
||||||
|
|
||||||
import com.github.luben.zstd.Zstd;
|
import com.github.luben.zstd.Zstd;
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.GsonBuilder;
|
|
||||||
import com.google.gson.reflect.TypeToken;
|
import com.google.gson.reflect.TypeToken;
|
||||||
import com.mojang.brigadier.CommandDispatcher;
|
|
||||||
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.config.JustBackupConfig;
|
import io.ib67.sfcraft.config.JustBackupConfig;
|
||||||
import io.ib67.sfcraft.config.StorageOption;
|
import io.ib67.sfcraft.config.StorageOption;
|
||||||
import io.ib67.sfcraft.config.serializer.StorageOptionSerializer;
|
|
||||||
import io.ib67.sfcraft.mixin.LevelStorageAccessor;
|
|
||||||
import io.ib67.sfcraft.mixin.MixinMinecraftServer;
|
|
||||||
import io.ib67.sfcraft.strategy.BackupStrategy;
|
import io.ib67.sfcraft.strategy.BackupStrategy;
|
||||||
import io.ib67.sfcraft.strategy.BackupTracker;
|
import io.ib67.sfcraft.strategy.BackupTracker;
|
||||||
import io.ib67.sfcraft.strategy.LocalBackupStrategy;
|
import io.ib67.sfcraft.strategy.LocalBackupStrategy;
|
||||||
import io.ib67.sfcraft.strategy.S3BackupStrategy;
|
import io.ib67.sfcraft.strategy.S3BackupStrategy;
|
||||||
|
import io.netty.buffer.ByteBufAllocator;
|
||||||
import lombok.SneakyThrows;
|
import lombok.SneakyThrows;
|
||||||
import net.fabricmc.api.ModInitializer;
|
import net.fabricmc.api.ModInitializer;
|
||||||
|
|
||||||
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||||
import net.jpountz.lz4.LZ4BlockInputStream;
|
|
||||||
import net.minecraft.command.CommandRegistryAccess;
|
|
||||||
import net.minecraft.server.MinecraftServer;
|
import net.minecraft.server.MinecraftServer;
|
||||||
import net.minecraft.server.command.CommandManager;
|
|
||||||
import net.minecraft.server.command.ServerCommandSource;
|
|
||||||
import net.minecraft.server.world.ServerWorld;
|
|
||||||
import net.minecraft.text.Text;
|
import net.minecraft.text.Text;
|
||||||
import org.jetbrains.annotations.UnknownNullability;
|
import org.jetbrains.annotations.UnknownNullability;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.RandomAccessFile;
|
import java.nio.file.*;
|
||||||
import java.nio.file.Files;
|
import java.util.ArrayList;
|
||||||
import java.nio.file.Path;
|
import java.util.HashMap;
|
||||||
import java.time.LocalDateTime;
|
import java.util.Map;
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.stream.Collectors;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
|
||||||
|
|
||||||
import static net.minecraft.server.command.CommandManager.argument;
|
|
||||||
import static net.minecraft.server.command.CommandManager.literal;
|
|
||||||
|
|
||||||
public class JustBackupMod implements ModInitializer {
|
public class JustBackupMod implements ModInitializer {
|
||||||
public static final String MOD_ID = "justbackup";
|
public static final String MOD_ID = "justbackup";
|
||||||
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
||||||
/**
|
protected final Map<String, BackupSubject> backupSubjects = new HashMap<>();
|
||||||
* This state indicates:
|
protected volatile MinecraftServer server;
|
||||||
* 1. A minecraft auto-save is in-progress.
|
protected volatile boolean suspend;
|
||||||
* 2. The backup is in-progress
|
protected byte[] zstdDict;
|
||||||
*/
|
|
||||||
public static final AtomicReference<IOState> BACKUP_LOCK = new AtomicReference<>(IOState.IDLE);
|
|
||||||
public static final Gson GSON = new GsonBuilder().setPrettyPrinting()
|
|
||||||
.registerTypeAdapter(StorageOption.class, new StorageOptionSerializer(
|
|
||||||
Map.of("s3", StorageOption.S3.class,
|
|
||||||
"local", StorageOption.Local.class)
|
|
||||||
))
|
|
||||||
.create();
|
|
||||||
protected ScheduledExecutorService scheduledBackupExecutor;
|
protected ScheduledExecutorService scheduledBackupExecutor;
|
||||||
protected JustBackupConfig config;
|
protected JustBackupConfig config;
|
||||||
protected BackupTracker tracker;
|
protected BackupTracker tracker;
|
||||||
protected Path backupIndexPath;
|
protected Path backupIndexPath;
|
||||||
protected volatile MinecraftServer server;
|
|
||||||
protected volatile boolean suspend;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
@@ -89,20 +57,55 @@ public class JustBackupMod implements ModInitializer {
|
|||||||
if (Files.notExists(backupIndexPath))
|
if (Files.notExists(backupIndexPath))
|
||||||
Files.createFile(backupIndexPath);
|
Files.createFile(backupIndexPath);
|
||||||
|
|
||||||
config = GSON.fromJson(Files.readString(configPath), JustBackupConfig.class);
|
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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backupSubjects.put(k, new BackupSubject(k, subjectPath, new ArrayList<>()));
|
||||||
|
});
|
||||||
|
if (config.zstdDictPath() != null && !config.zstdDictPath().isEmpty()) {
|
||||||
|
zstdDict = Files.readAllBytes(Path.of(config.zstdDictPath()));
|
||||||
|
}
|
||||||
var strategy = createStrategy();
|
var strategy = createStrategy();
|
||||||
this.tracker = new BackupTracker(strategy, GSON.fromJson(Files.readString(backupIndexPath), new TypeToken<>() {
|
this.tracker = new BackupTracker(strategy, Globals.GSON.fromJson(Files.readString(backupIndexPath), new TypeToken<>() {
|
||||||
}));
|
}));
|
||||||
LOGGER.info("Configuration has been loaded. Using storage option: {}", config.option().type());
|
LOGGER.info("Configuration has been loaded. Using storage option: {}", config.option().type());
|
||||||
scheduledBackupExecutor = Executors.newSingleThreadScheduledExecutor();
|
scheduledBackupExecutor = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
registerWatchService();
|
||||||
registerHandlers();
|
registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void registerWatchService() {
|
||||||
|
var watcherThread = new FileWatcherThread(
|
||||||
|
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
|
||||||
|
);
|
||||||
|
ServerLifecycleEvents.SERVER_STOPPED.register(server -> {
|
||||||
|
watcherThread.interrupt();
|
||||||
|
});
|
||||||
|
watcherThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleFileChanges(String subject, Path path) {
|
||||||
|
synchronized (backupSubjects) {
|
||||||
|
backupSubjects.get(subject).changedFiles().add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SneakyThrows
|
||||||
private void registerHandlers() {
|
private void registerHandlers() {
|
||||||
ServerLifecycleEvents.SERVER_STARTED.register(server -> {
|
ServerLifecycleEvents.SERVER_STARTED.register(server -> {
|
||||||
LOGGER.info("Launching background threads...");
|
LOGGER.info("Launching background threads...");
|
||||||
this.server = server;
|
this.server = server;
|
||||||
scheduledBackupExecutor.scheduleAtFixedRate(this::issueBackup,
|
if (config.bundleFullAtStartup()) {
|
||||||
|
backupAll(false);
|
||||||
|
}
|
||||||
|
scheduledBackupExecutor.scheduleAtFixedRate(() -> backupAll(config.incremental()),
|
||||||
config.backupIntervalMinutes(), config.backupIntervalMinutes(), TimeUnit.MINUTES);
|
config.backupIntervalMinutes(), config.backupIntervalMinutes(), TimeUnit.MINUTES);
|
||||||
});
|
});
|
||||||
ServerLifecycleEvents.SERVER_STOPPED.register(server -> {
|
ServerLifecycleEvents.SERVER_STOPPED.register(server -> {
|
||||||
@@ -123,12 +126,43 @@ public class JustBackupMod implements ModInitializer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Backup> issueBackup() {
|
public CompletableFuture<Map<String, Backup>> backupAll(boolean incremental) {
|
||||||
|
var map = new HashMap<String, CompletableFuture<Backup>>();
|
||||||
|
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>();
|
||||||
|
map.forEach((k, v) -> _map.put(k, v.join()));
|
||||||
|
return _map;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Backup> issueBackup(String subjectName, boolean incremental) {
|
||||||
if (suspend) {
|
if (suspend) {
|
||||||
return CompletableFuture.failedFuture(new IllegalStateException("Backup is temporarily disabled"));
|
return CompletableFuture.failedFuture(new IllegalStateException("Backup is temporarily disabled"));
|
||||||
}
|
}
|
||||||
server.getPlayerManager().broadcast(Text.literal("The server will begin the backup shortly, you may experience some lag."), false);
|
var subject = backupSubjects.get(subjectName);
|
||||||
var worker = new BackupWorker(server, tracker, config);
|
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).toList());
|
||||||
|
} catch (IOException e) {
|
||||||
|
return CompletableFuture.failedFuture(new IllegalArgumentException("Cannot perform full backup for " + subjectName, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var finalSubject = subject;
|
||||||
|
var worker = new BackupWorker(server, tracker, Path.of(config.temporaryBackupDir()),
|
||||||
|
subject, 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(worker, scheduledBackupExecutor).whenComplete(this::onBackupComplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +172,7 @@ public class JustBackupMod implements ModInitializer {
|
|||||||
server.getPlayerManager().broadcast(Text.literal("Backup failed. For administrators, please check your server console."), false);
|
server.getPlayerManager().broadcast(Text.literal("Backup failed. For administrators, please check your server console."), false);
|
||||||
LOGGER.error(throwable.getMessage(), throwable);
|
LOGGER.error(throwable.getMessage(), throwable);
|
||||||
} else {
|
} else {
|
||||||
Files.writeString(backupIndexPath, GSON.toJson(tracker.getTrackedBackups()));
|
Files.writeString(backupIndexPath, Globals.GSON.toJson(tracker.getTrackedBackups()));
|
||||||
server.getPlayerManager().broadcast(Text.literal("Backup success. " + backup), false);
|
server.getPlayerManager().broadcast(Text.literal("Backup success. " + backup), false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,17 +188,21 @@ public class JustBackupMod implements ModInitializer {
|
|||||||
private void saveConfig(Path configPath) {
|
private void saveConfig(Path configPath) {
|
||||||
LOGGER.info("Saving default configuration");
|
LOGGER.info("Saving default configuration");
|
||||||
var cfg = new JustBackupConfig(
|
var cfg = new JustBackupConfig(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
60,
|
60,
|
||||||
5,
|
5,
|
||||||
Zstd.defaultCompressionLevel(),
|
Zstd.defaultCompressionLevel(),
|
||||||
|
4,
|
||||||
true,
|
true,
|
||||||
"./.backup_cache",
|
"./.backup_cache",
|
||||||
|
null, null,
|
||||||
new StorageOption.Local("backups", 0)
|
new StorageOption.Local("backups", 0)
|
||||||
);
|
);
|
||||||
var path = Path.of(cfg.temporaryBackupDir());
|
var path = Path.of(cfg.temporaryBackupDir());
|
||||||
if (Files.notExists(path)) {
|
if (Files.notExists(path)) {
|
||||||
Files.createDirectories(path);
|
Files.createDirectories(path);
|
||||||
}
|
}
|
||||||
Files.writeString(configPath, GSON.toJson(cfg));
|
Files.writeString(configPath, Globals.GSON.toJson(cfg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,39 @@
|
|||||||
package io.ib67.sfcraft.config;
|
package io.ib67.sfcraft.config;
|
||||||
|
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
import org.apache.commons.lang3.Validate;
|
import org.apache.commons.lang3.Validate;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
public record JustBackupConfig(
|
public record JustBackupConfig(
|
||||||
|
boolean bundleFullAtStartup,
|
||||||
|
boolean incremental,
|
||||||
long backupIntervalMinutes,
|
long backupIntervalMinutes,
|
||||||
int keepBackups,
|
int keepBackups,
|
||||||
int compressionLevel,
|
int compressionLevel,
|
||||||
|
int compressionWorkerThreads,
|
||||||
boolean allowGunzip,
|
boolean allowGunzip,
|
||||||
String temporaryBackupDir,
|
String temporaryBackupDir,
|
||||||
|
String zstdDictPath,
|
||||||
|
Map<String, String> backupSubjects,
|
||||||
StorageOption option
|
StorageOption option
|
||||||
) {
|
) {
|
||||||
|
@SneakyThrows
|
||||||
public JustBackupConfig {
|
public JustBackupConfig {
|
||||||
Validate.isTrue(backupIntervalMinutes > 0, "backup interval must be positive");
|
Validate.isTrue(backupIntervalMinutes > 0, "backup interval must be positive");
|
||||||
Validate.isTrue(keepBackups > 0, "keep backups must be positive");
|
Validate.isTrue(keepBackups > 0, "keep backups must be positive");
|
||||||
|
Validate.isTrue(compressionWorkerThreads >= 0, "compression worker threads must be positive");
|
||||||
Validate.isTrue(compressionLevel > 0, "compression level must be positive");
|
Validate.isTrue(compressionLevel > 0, "compression level must be positive");
|
||||||
|
if (backupSubjects == null) backupSubjects = new HashMap<>();
|
||||||
|
if (backupSubjects.isEmpty()) {
|
||||||
|
log.warn("Backup subjects are not configured. The mod will not work!");
|
||||||
|
}
|
||||||
Validate.notNull(option, "option cannot be null");
|
Validate.notNull(option, "option cannot be null");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
package io.ib67.sfcraft.mixin;
|
package io.ib67.sfcraft.mixin;
|
||||||
|
|
||||||
|
import io.ib67.sfcraft.Globals;
|
||||||
import io.ib67.sfcraft.IOState;
|
import io.ib67.sfcraft.IOState;
|
||||||
import io.ib67.sfcraft.JustBackupMod;
|
|
||||||
import lombok.Getter;
|
|
||||||
import net.minecraft.server.MinecraftServer;
|
import net.minecraft.server.MinecraftServer;
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
import org.spongepowered.asm.mixin.Shadow;
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
import org.spongepowered.asm.mixin.Unique;
|
|
||||||
import org.spongepowered.asm.mixin.injection.At;
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
import org.spongepowered.asm.mixin.injection.Inject;
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
import javax.security.auth.callback.Callback;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
@Mixin(MinecraftServer.class)
|
@Mixin(MinecraftServer.class)
|
||||||
public abstract class MixinMinecraftServer {
|
public abstract class MixinMinecraftServer {
|
||||||
|
|
||||||
@@ -28,7 +23,7 @@ public abstract class MixinMinecraftServer {
|
|||||||
// is already backing up
|
// is already backing up
|
||||||
this.ticksUntilAutosave = getAutosaveInterval();
|
this.ticksUntilAutosave = getAutosaveInterval();
|
||||||
IOState witness;
|
IOState witness;
|
||||||
while ((witness = JustBackupMod.BACKUP_LOCK.compareAndExchange(IOState.IDLE, IOState.SAVING_WORLD)) == IOState.STORAGE_SYNC) {
|
while ((witness = Globals.BACKUP_LOCK.compareAndExchange(IOState.IDLE, IOState.SAVING_WORLD)) == IOState.STORAGE_SYNC) {
|
||||||
try {
|
try {
|
||||||
Thread.sleep(1000);
|
Thread.sleep(1000);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
@@ -41,6 +36,6 @@ public abstract class MixinMinecraftServer {
|
|||||||
|
|
||||||
@Inject(method = "runAutosave", at = @At("RETURN"))
|
@Inject(method = "runAutosave", at = @At("RETURN"))
|
||||||
private void backup$disableProtect(CallbackInfo ci) {
|
private void backup$disableProtect(CallbackInfo ci) {
|
||||||
JustBackupMod.BACKUP_LOCK.setRelease(IOState.IDLE);
|
Globals.BACKUP_LOCK.setRelease(IOState.IDLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
package io.ib67.sfcraft.mixin;
|
package io.ib67.sfcraft.mixin;
|
||||||
|
|
||||||
|
import io.ib67.sfcraft.Globals;
|
||||||
import io.ib67.sfcraft.IOState;
|
import io.ib67.sfcraft.IOState;
|
||||||
import io.ib67.sfcraft.JustBackupMod;
|
|
||||||
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||||
import it.unimi.dsi.fastutil.objects.ObjectIterator;
|
|
||||||
import lombok.SneakyThrows;
|
import lombok.SneakyThrows;
|
||||||
import net.minecraft.world.storage.RegionBasedStorage;
|
import net.minecraft.world.storage.RegionBasedStorage;
|
||||||
import net.minecraft.world.storage.RegionFile;
|
import net.minecraft.world.storage.RegionFile;
|
||||||
@@ -11,9 +10,6 @@ import org.spongepowered.asm.mixin.Final;
|
|||||||
import org.spongepowered.asm.mixin.Mixin;
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
import org.spongepowered.asm.mixin.Overwrite;
|
import org.spongepowered.asm.mixin.Overwrite;
|
||||||
import org.spongepowered.asm.mixin.Shadow;
|
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;
|
|
||||||
|
|
||||||
@Mixin(RegionBasedStorage.class)
|
@Mixin(RegionBasedStorage.class)
|
||||||
public abstract class MixinRegionBasedStorage {
|
public abstract class MixinRegionBasedStorage {
|
||||||
@@ -31,12 +27,12 @@ public abstract class MixinRegionBasedStorage {
|
|||||||
// allowed states:
|
// allowed states:
|
||||||
// STORAGE_SYNC, AUTO_SAVE, IDLE
|
// STORAGE_SYNC, AUTO_SAVE, IDLE
|
||||||
// we try
|
// we try
|
||||||
var witness = JustBackupMod.BACKUP_LOCK.getAndSet(IOState.STORAGE_SYNC);
|
var witness = Globals.BACKUP_LOCK.getAndSet(IOState.STORAGE_SYNC);
|
||||||
if (witness == IOState.BACKUP) {
|
if (witness == IOState.BACKUP) {
|
||||||
// yield out and keep blocking.
|
// yield out and keep blocking.
|
||||||
// The backup worker will wait for us setting this back to backup.
|
// The backup worker will wait for us setting this back to backup.
|
||||||
JustBackupMod.BACKUP_LOCK.set(IOState.BACKUP);
|
Globals.BACKUP_LOCK.set(IOState.BACKUP);
|
||||||
while (JustBackupMod.BACKUP_LOCK.getAcquire() == IOState.BACKUP) {
|
while (Globals.BACKUP_LOCK.getAcquire() == IOState.BACKUP) {
|
||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,8 +45,8 @@ public abstract class MixinRegionBasedStorage {
|
|||||||
// we asserts that there are no other threads calling sync()
|
// we asserts that there are no other threads calling sync()
|
||||||
// so we can just jump back to the witness value.
|
// so we can just jump back to the witness value.
|
||||||
// witness: AUTO_SAVE or IDLE
|
// witness: AUTO_SAVE or IDLE
|
||||||
JustBackupMod.BACKUP_LOCK.compareAndExchange(IOState.STORAGE_SYNC, witness);
|
Globals.BACKUP_LOCK.compareAndExchange(IOState.STORAGE_SYNC, witness);
|
||||||
JustBackupMod.BACKUP_LOCK.setRelease(witness);
|
Globals.BACKUP_LOCK.setRelease(witness);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ public interface BackupStrategy {
|
|||||||
* create a backup
|
* create a backup
|
||||||
* @return a key to this backup
|
* @return a key to this backup
|
||||||
*/
|
*/
|
||||||
Backup createBackup(Path source, Path bundle);
|
Backup createBackup(String subject, Path source, Path bundle);
|
||||||
|
|
||||||
void recoverBackup(Backup backup, Path restorePath);
|
void recoverBackup(Backup backup, Path restorePath);
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ public class LocalBackupStrategy implements BackupStrategy {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
public Backup createBackup(Path from, Path pathToBundle) {
|
public Backup createBackup(String subject, Path from, Path pathToBundle) {
|
||||||
var result = new Backup(
|
var result = new Backup(
|
||||||
pathToBundle.getFileName().toString(),
|
pathToBundle.getFileName().toString(),
|
||||||
pathToBundle.getFileName().toString(),
|
pathToBundle.getFileName().toString(),
|
||||||
"local", from.toString(), false, Files.size(pathToBundle));
|
"local", from.toString(), false, Files.size(pathToBundle));
|
||||||
Files.move(pathToBundle, backupParentRoot.resolve(pathToBundle.getFileName()));
|
var parent = backupParentRoot.resolve(subject);
|
||||||
|
if(Files.notExists(parent)) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
Files.move(pathToBundle, parent.resolve(pathToBundle.getFileName()));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +44,7 @@ public class LocalBackupStrategy implements BackupStrategy {
|
|||||||
// check if it is compressed
|
// check if it is compressed
|
||||||
var backupFile = backupParentRoot.resolve(backup.backupKey());
|
var backupFile = backupParentRoot.resolve(backup.backupKey());
|
||||||
if (Files.notExists(backupFile)) throw new IllegalArgumentException("Backup " + backupFile + " does not exist");
|
if (Files.notExists(backupFile)) throw new IllegalArgumentException("Backup " + backupFile + " does not exist");
|
||||||
BundleReader.extract(backupFile, restorePath);
|
BundleReader.builder().build().extract(backupFile, restorePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ public class S3BackupStrategy implements BackupStrategy {
|
|||||||
var target = temporaryDownloadPath.resolve("s3_" + System.currentTimeMillis());
|
var target = temporaryDownloadPath.resolve("s3_" + System.currentTimeMillis());
|
||||||
try (var fs = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
try (var fs = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||||
resp.transferTo(fs);
|
resp.transferTo(fs);
|
||||||
BundleReader.extract(target, restorePath);
|
BundleReader.builder().build().extract(target, restorePath);
|
||||||
} finally {
|
} finally {
|
||||||
Files.deleteIfExists(target);
|
Files.deleteIfExists(target);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user