Made backup manager more generic

This commit is contained in:
2022-12-19 16:43:46 +01:00
parent 9818bca757
commit 52467dc556
19 changed files with 995 additions and 709 deletions

View File

@@ -0,0 +1,131 @@
package fr.pandacube.lib.core.backup;
import fr.pandacube.lib.chat.Chat;
import fr.pandacube.lib.util.Log;
import net.md_5.bungee.api.ChatColor;
import java.io.File;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import static fr.pandacube.lib.chat.ChatStatic.text;
public abstract class BackupCleaner implements UnaryOperator<TreeSet<LocalDateTime>> {
private static final boolean testOnly = true; // if true, no files are deleted
public static BackupCleaner KEEPING_N_LAST(int n) {
return new BackupCleaner() {
@Override
public TreeSet<LocalDateTime> apply(TreeSet<LocalDateTime> archives) {
return archives.descendingSet().stream()
.limit(n)
.collect(Collectors.toCollection(TreeSet::new));
}
};
}
public static BackupCleaner KEEPING_1_EVERY_N_MONTH(int n) {
return new BackupCleaner() {
@Override
public TreeSet<LocalDateTime> apply(TreeSet<LocalDateTime> localDateTimes) {
return localDateTimes.stream()
.collect(Collectors.groupingBy(
ldt -> {
return ldt.getYear() * 4 + ldt.getMonthValue() / n;
},
TreeMap::new,
Collectors.minBy(LocalDateTime::compareTo))
)
.values()
.stream()
.filter(Optional::isPresent)
.map(Optional::get)
.collect(Collectors.toCollection(TreeSet::new));
}
};
}
public BackupCleaner merge(BackupCleaner other) {
BackupCleaner self = this;
return new BackupCleaner() {
@Override
public TreeSet<LocalDateTime> apply(TreeSet<LocalDateTime> archives) {
TreeSet<LocalDateTime> merged = new TreeSet<>();
merged.addAll(self.apply(archives));
merged.addAll(other.apply(archives));
return merged;
}
};
}
public void cleanupArchives(File archiveDir, String compressDisplayName) {
String[] files = archiveDir.list();
Log.info("[Backup] Cleaning up backup directory " + ChatColor.GRAY + compressDisplayName + ChatColor.RESET + "...");
TreeMap<LocalDateTime, File> datedFiles = new TreeMap<>();
for (String filename : files) {
File file = new File(archiveDir, filename);
if (!filename.matches("\\d{8}-\\d{6}\\.zip")) {
Log.warning("[Backup] " + ChatColor.GRAY + compressDisplayName + ChatColor.RESET + " Invalid file in backup directory: " + filename);
continue;
}
String dateTimeStr = filename.substring(0, filename.length() - 4);
LocalDateTime ldt;
try {
ldt = LocalDateTime.parse(dateTimeStr, BackupProcess.dateFileNameFormatter);
} catch (DateTimeParseException e) {
Log.warning("[Backup] " + ChatColor.GRAY + compressDisplayName + ChatColor.RESET + " Unable to parse file name to a date-time: " + filename, e);
continue;
}
datedFiles.put(ldt, file);
}
TreeSet<LocalDateTime> keptFiles = apply(new TreeSet<>(datedFiles.keySet()));
Chat c = text("[Backup] ")
.then(text(compressDisplayName).gray())
.thenText(testOnly ? " Archive cleanup debug (no files are actually deleted):\n" : " Deleted archive files:\n");
boolean oneDeleted = false;
for (Entry<LocalDateTime, File> datedFile : datedFiles.entrySet()) {
if (keptFiles.contains(datedFile.getKey())) {
if (testOnly)
c.thenText("- " + datedFile.getValue().getName() + " ")
.thenSuccess("kept")
.thenText(".\n");
continue;
}
oneDeleted = true;
c.thenText("- " + datedFile.getValue().getName() + " ");
if (testOnly)
c.thenFailure("deleted")
.thenText(".\n");
else
datedFile.getValue().delete();
}
if (testOnly || oneDeleted)
Log.warning(c.getLegacyText());
Log.info("[Backup] Backup directory " + ChatColor.GRAY + compressDisplayName + ChatColor.RESET + " cleaned.");
}
}

View File

@@ -0,0 +1,104 @@
package fr.pandacube.lib.core.backup;
import fc.cron.CronExpression;
import fr.pandacube.lib.util.Log;
import java.io.File;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.LongStream;
public class BackupManager extends TimerTask {
private final File backupDirectory;
protected final Persist persist;
protected final List<BackupProcess> backupQueue = new ArrayList<>();
/* package */ final AtomicReference<BackupProcess> runningBackup = new AtomicReference<>();
private final Timer schedulerTimer = new Timer();
public BackupManager(File backupDirectory) {
this.backupDirectory = backupDirectory;
persist = new Persist(this);
long nextMinute = ZonedDateTime.now().plusMinutes(1).withSecond(0).withNano(0)
.toInstant().toEpochMilli();
schedulerTimer.scheduleAtFixedRate(this, new Date(nextMinute), 60_000);
}
protected void addProcess(BackupProcess process) {
process.displayNextSchedule();
backupQueue.add(process);
}
public File getBackupDirectory() {
return backupDirectory;
}
public synchronized void run() {
BackupProcess tmp;
if ((tmp = runningBackup.get()) != null) {
tmp.logProgress();
}
else {
backupQueue.sort(null);
for (BackupProcess process : backupQueue) {
if (System.currentTimeMillis() >= process.getNext() && process.couldRunNow()) {
process.run();
return;
}
}
}
}
public synchronized void onDisable() {
schedulerTimer.cancel();
if (runningBackup.get() != null) {
Log.warning("[Backup] Waiting after the end of a backup...");
BackupProcess tmp;
while ((tmp = runningBackup.get()) != null) {
try {
tmp.logProgress();
// wait 5 seconds between each progress log
// but check if the process has ended each .5 seconds
for (int i = 0; i < 10; i++) {
if (runningBackup.get() == null)
break;
Thread.sleep(500);
}
} catch (Throwable e) { // could occur because of synchronization errors/interruption/...
break;
}
}
}
persist.save();
}
}

View File

@@ -0,0 +1,270 @@
package fr.pandacube.lib.core.backup;
import fc.cron.CronExpression;
import fr.pandacube.lib.util.FileUtils;
import fr.pandacube.lib.util.Log;
import net.md_5.bungee.api.ChatColor;
import java.io.File;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiPredicate;
public abstract class BackupProcess implements Comparable<BackupProcess>, Runnable {
private final BackupManager backupManager;
public final String identifier;
protected ZipCompressor compressor = null;
private boolean enabled = true;
private String scheduling = "0 2 * * *"; // cron format, here is everyday at 2am
private BackupCleaner backupCleaner = null;
private List<String> ignoreList = new ArrayList<>();
protected BackupProcess(BackupManager bm, final String n) {
backupManager = bm;
identifier = n;
}
public BackupManager getBackupManager() {
return backupManager;
}
public String getIdentifier() {
return identifier;
}
protected String getDisplayName() {
return getIdentifier();
}
@Override
public int compareTo(final BackupProcess process) {
return Long.compare(getNext(), process.getNext());
}
public abstract BiPredicate<File, String> getFilenameFilter();
public abstract File getSourceDir();
protected abstract File getTargetDir();
protected abstract void onBackupStart();
protected abstract void onBackupEnd(boolean success);
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getScheduling() {
return scheduling;
}
public void setScheduling(String scheduling) {
this.scheduling = scheduling;
}
public BackupCleaner getBackupCleaner() {
return backupCleaner;
}
public void setBackupCleaner(BackupCleaner backupCleaner) {
this.backupCleaner = backupCleaner;
}
@Override
public void run() {
getBackupManager().runningBackup.set(this);
try {
BiPredicate<File, String> filter = getFilenameFilter();
File sourceDir = getSourceDir();
if (!sourceDir.exists()) {
Log.warning("[Backup] Unable to compress " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET + ": source directory " + sourceDir + " doesnt exist");
return;
}
File targetDir = getTargetDir();
File target = new File(targetDir, getDateFileName() + ".zip");
onBackupStart();
new Thread(() -> {
Log.info("[Backup] Starting for " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET + " ...");
compressor = new ZipCompressor(sourceDir, target, 9, filter);
boolean success = false;
try {
compressor.compress();
success = true;
Log.info("[Backup] Finished for " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET);
try {
BackupCleaner cleaner = getBackupCleaner();
if (cleaner != null)
cleaner.cleanupArchives(targetDir, getDisplayName());
} catch (Exception e) {
Log.severe(e);
}
}
catch (final Exception e) {
Log.severe("[Backup] Failed: " + sourceDir + " -> " + target, e);
FileUtils.delete(target);
if (target.exists())
Log.warning("unable to delete: " + target);
} finally {
getBackupManager().runningBackup.set(null);
onBackupEnd(success);
displayNextSchedule();
}
}, "Backup Thread " + identifier).start();
} catch (Throwable t) {
getBackupManager().runningBackup.set(null);
throw t;
}
}
public abstract void displayNextSchedule();
public static final DateTimeFormatter dateFileNameFormatter = new DateTimeFormatterBuilder()
.appendValue(ChronoField.YEAR, 4)
.appendValue(ChronoField.MONTH_OF_YEAR, 2)
.appendValue(ChronoField.DAY_OF_MONTH, 2)
.appendLiteral('-')
.appendValue(ChronoField.HOUR_OF_DAY, 2)
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
.toFormatter();
private String getDateFileName() {
return dateFileNameFormatter.format(ZonedDateTime.now());
}
public void logProgress() {
if (compressor == null)
return;
Log.info("[Backup] " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET + ": " + compressor.getState().getLegacyText());
}
public boolean couldRunNow() {
if (!isEnabled())
return false;
if (!isDirty())
return false;
if (getNext() > System.currentTimeMillis())
return false;
return true;
}
public long getNext() {
if (!hasNextScheduled())
return Long.MAX_VALUE;
return getNextCompress(backupManager.persist.isDirtySince(identifier));
}
public boolean hasNextScheduled() {
return isEnabled() && isDirty();
}
public boolean isDirty() {
return backupManager.persist.isDirty(identifier);
}
public void setDirtySinceNow() {
backupManager.persist.setDirtySinceNow(identifier);
}
public void setNotDirty() {
backupManager.persist.setNotDirty(identifier);
}
/**
* get the timestamp (in ms) of when the next compress will run, depending on since when the files to compress are dirty.
* @param dirtySince the timestamp in ms since the files are dirty
* @return the timestamp in ms when the next compress of the files should be run, or 0 if it is not yet scheduled
*/
public long getNextCompress(long dirtySince) {
if (dirtySince == -1)
return 0;
CronExpression parsedScheduling;
try {
parsedScheduling = new CronExpression(getScheduling(), false);
} catch (IllegalArgumentException e) {
Log.severe("Invalid backup scheduling configuration '" + getScheduling() + "'.", e);
return 0;
}
return parsedScheduling.nextTimeAfter(ZonedDateTime.ofInstant(Instant.ofEpochMilli(dirtySince), ZoneId.systemDefault()))
.toInstant()
.toEpochMilli();
}
}

View File

@@ -0,0 +1,89 @@
package fr.pandacube.lib.core.backup;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import fr.pandacube.lib.core.json.Json;
import fr.pandacube.lib.util.Log;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class Persist {
protected final BackupManager backupManager;
private Map<String, Long> dirtySince = new HashMap<>();
private final File file;
// private final Set<String> dirtyWorldsSave = new HashSet<>();
public Persist(BackupManager bm) {
backupManager = bm;
file = new File(bm.getBackupDirectory(), "source-dirty-since.yml");
load();
}
public void reload() {
load();
}
protected void load() {
boolean loaded = false;
try {
dirtySince = Json.gson.fromJson(new FileReader(file), new TypeToken<Map<String, Long>>(){}.getType());
loaded = true;
}
catch (final FileNotFoundException ignored) { }
catch (final JsonParseException e) {
Log.severe("cannot load " + file, e);
}
if (!loaded) {
save();
}
}
public void save() {
try {
Json.gsonPrettyPrinting.toJson(dirtySince, new FileWriter(file, false));
}
catch (final FileNotFoundException ignored) { }
catch (final JsonParseException | IOException e) {
Log.severe("could not save " + file, e);
}
}
public void setDirtySinceNow(String id) {
dirtySince.put(id, System.currentTimeMillis());
save();
}
public void setNotDirty(String id) {
dirtySince.put(id, -1L);
save();
}
public boolean isDirty(String id) {
return isDirtySince(id) != -1;
}
public long isDirtySince(String id) {
if (!dirtySince.containsKey(id))
setDirtySinceNow(id);
return dirtySince.get(id);
}
}

View File

@@ -0,0 +1,125 @@
package fr.pandacube.lib.core.backup;
import com.google.common.io.Files;
import fr.pandacube.lib.util.Log;
import net.md_5.bungee.api.ChatColor;
import java.io.File;
import java.io.IOException;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.function.BiPredicate;
public class RotatedLogsBackupProcess extends BackupProcess {
final String logFileRegexPattern;
final File sourceLogDirectory;
final boolean inNewThread;
public RotatedLogsBackupProcess(BackupManager bm, boolean inNewThread, File sourceLogDir, String logFileRegexPattern) {
super(bm, "logs");
this.logFileRegexPattern = logFileRegexPattern;
sourceLogDirectory = sourceLogDir;
this.inNewThread = inNewThread;
super.setScheduling("0 1 * * *"); // do this every day at 1 am, by default
}
@Override
public void run() {
// do not call super. We override the zip archive process, we just want to copy log files, here
if (inNewThread) {
new Thread(this::actuallyRun, "Backup Thread " + identifier).start();
}
else {
actuallyRun();
}
}
private void actuallyRun() {
Log.info("[Backup] Starting for " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET + " ...");
try {
// wait a little after the log message above, in case the log file rotation has to be performed.
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
onBackupStart();
boolean success = false;
File targetDir = getTargetDir();
try {
List<File> filesToMove = getFilesToMove();
for (File source : filesToMove) {
try {
Files.move(source, new File(targetDir, source.getName()));
} catch (IOException e) {
Log.severe("Unable to move file " + source + " into " + targetDir);
}
}
success = true;
Log.info("[Backup] Finished for " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET);
} catch (final Exception e) {
Log.severe("[Backup] Failed for : " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET, e);
} finally {
onBackupEnd(success);
displayNextSchedule();
}
}
public List<File> getFilesToMove() {
List<File> ret = new ArrayList<>();
for (File f : getSourceDir().listFiles()) {
if (f.getName().matches(logFileRegexPattern))
ret.add(f);
}
return ret;
}
@Override
public BiPredicate<File, String> getFilenameFilter() {
return null;
}
@Override
public File getSourceDir() {
return sourceLogDirectory;
}
@Override
protected File getTargetDir() {
return new File(getBackupManager().getBackupDirectory(), "logs");
}
@Override
protected void onBackupStart() {
}
@Override
protected void onBackupEnd(boolean success) {
setDirtySinceNow();
}
@Override
public void displayNextSchedule() {
Log.info("[Backup] " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET + " next backup on "
+ DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG).format(new Date(getNext())));
}
}

View File

@@ -0,0 +1,194 @@
package fr.pandacube.lib.core.backup;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiPredicate;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import fr.pandacube.lib.chat.Chat;
import fr.pandacube.lib.util.MemoryUtil;
import fr.pandacube.lib.util.TimeUtil;
/**
* Handles the creation of a zip file that will have the content of a provided folder.
*/
public class ZipCompressor {
private static final int BUFFER_SIZE = 16 * 1024;
private final File srcDir, destFile;
private final int compressionLevel;
private final BiPredicate<File, String> filter;
private final List<Entry> entriesToCompress;
private ZipOutputStream zipOutStream;
private final Object stateLock = new Object();
private final long inputByteSize;
private long startTime;
private long elapsedByte = 0;
private Exception exception = null;
private boolean started = false;
private boolean finished = false;
/**
* Creates a new zip compressor.
* @param s the source directory.
* @param d the destination file.
* @param c the compression level, used in {@link ZipOutputStream#setLevel(int)} .
* @param f a filter that returns true for the files to include in the zip file, false to exclude.
*/
public ZipCompressor(File s, File d, int c, BiPredicate<File, String> f) {
srcDir = s;
destFile = d;
compressionLevel = c;
filter = f;
entriesToCompress = new ArrayList<>();
inputByteSize = addEntry("");
}
/**
* Returns a displayable representation of the running compression.
* @return a displayable representation of the running compression.
*/
public Chat getState() {
synchronized (stateLock) {
if (!started) {
return Chat.text("Démarrage...");
}
else if (!finished && exception == null) {
float progress = getProgress();
long elapsedTime = System.nanoTime() - startTime;
long remainingTime = (long)(elapsedTime / progress) - elapsedTime;
return Chat.chat()
.infoColor()
.thenData(Math.round(progress*100*10)/10 + "% ")
.thenText("(")
.thenData(MemoryUtil.humanReadableSize(elapsedByte) + "/" + MemoryUtil.humanReadableSize(inputByteSize))
.thenText(") - Temps restant estimé : ")
.thenData(TimeUtil.durationToString(remainingTime / 1000000));
}
else if (exception != null) {
return Chat.failureText("Erreur lors de l'archivage (voir console pour les détails)");
}
else { // finished
return Chat.successText("Terminé !");
}
}
}
/**
* Gets the progress of the running compression.
* @return the progress of the running compression 0 when it starts and 1 when it finishes.
*/
public float getProgress() {
if (!started)
return 0;
if (finished)
return 1;
return elapsedByte / (float) inputByteSize;
}
/**
* Run the compression on the current thread, and returns after the end of the compression.
* Should be run asynchronously (not on Server Thread).
* @throws Exception if an error occurs during compression.
*/
public void compress() throws Exception {
destFile.getParentFile().mkdirs();
try(ZipOutputStream zipStream = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(destFile), BUFFER_SIZE))) {
zipOutStream = zipStream;
zipOutStream.setLevel(compressionLevel);
synchronized (stateLock) {
startTime = System.nanoTime();
started = true;
}
for (Entry entry : entriesToCompress) {
entry.zip();
}
synchronized (stateLock) {
finished = true;
}
} catch (Exception e) {
synchronized (stateLock) {
exception = e;
}
throw e;
}
}
private long addEntry(String currentEntry) {
final File currentFile = new File(srcDir, currentEntry);
if (!currentFile.exists())
return 0;
if (currentFile.isDirectory()) {
if (!currentEntry.isEmpty()) { // it's not the zip root directory
currentEntry += "/";
entriesToCompress.add(new Entry(currentFile, currentEntry));
}
long sum = 0;
for (String child : currentFile.list()) {
String childEntry = currentEntry + child;
if (filter.test(new File(currentFile, child), childEntry))
sum += addEntry(childEntry);
}
return sum;
}
else { // is a file
entriesToCompress.add(new Entry(currentFile, currentEntry));
return currentFile.length();
}
}
private class Entry {
File file;
String entry;
Entry(File f, String e) {
file = f;
entry = e;
}
void zip() throws IOException {
ZipEntry zipEntry = new ZipEntry(entry);
BasicFileAttributes attributes = Files.readAttributes(file.toPath(), BasicFileAttributes.class);
if (attributes.isDirectory()) {
zipOutStream.putNextEntry(zipEntry);
zipOutStream.closeEntry();
}
else {
zipEntry.setTime(attributes.lastModifiedTime().toMillis());
zipOutStream.putNextEntry(zipEntry);
try {
Files.copy(file.toPath(), zipOutStream);
}
finally {
zipOutStream.closeEntry();
}
synchronized (stateLock) {
elapsedByte += attributes.size();
}
}
}
}
}