## Adds Auto Archiving Backups are archived once an hour, day, and month. Archives older than 60 days are pruned automatically. _Note: Automatic pruning is off by default_ ### Archive compression format The following formats are supported: * Zstd (The fastest with best compression ratio) * GZip * Zip * None (Tar) _Note: By default archives use [zstandard](http://facebook.github.io/zstd/) format. The archive format can be changed in modernuo.json `autoArchive.compressionFormat`_ ### Restoring world from archive Move the archive to the Saves folder (tar.zst file). On startup the server will extract the file and restore the latest save. See pictures below. ### Manually extracting an archives #### Windows * Use the latest version of [7-zip w/ ZStandard](https://github.com/mcmilk/7-Zip-zstd/releases/latest) 1. Extract the `.tar.zst` file. 2. Extract the `.tar` file. (Yes you have to do it in two steps) * On Windows 10 you can use the command line. `zstd.exe` is in the Assemblies folder after building ModernUO. 1. `tar --use-compress-program "Distribution\Assemblies\zstd.exe -d" -xvf "Archives\Hourly\archivefile.tar.zst" -C "path to where you want to extract it"` #### Mac * Install [Keka](https://www.keka.io) 1. Drop the .tar.zst onto the keka interface. #### Linux 1. Install zstd from a package manager 2. Run `tar -I zstd -xvf "Archives\Hourly\archivefile.tar.zst" -C "path to where you want to extract it"` ### Other changes * Changes Autosave to occur at the same time no matter when the server is booted. * Adds `[SaveFrequency <delay> [warning]`command to set save frequency and warning frequency in-game. * Adds support for time zones that are configurable. The system timezone can also be manually configured. Check `TimeZoneHandler.cs` for details. <img width="257" alt="Screen Shot 2021-09-22 at 11 23 18 PM" src="https://user-images.githubusercontent.com/3953314/134464929-a5bf3cd8-2ef0-4476-a9ad-71d0816a19ac.png"> <img width="241" alt="Screen Shot 2021-09-22 at 11 23 39 PM" src="https://user-images.githubusercontent.com/3953314/134464945-5f6f96dc-3d1e-434d-8256-5c6b3705e786.png"> <img width="836" alt="Screen Shot 2021-09-22 at 11 34 39 PM" src="https://user-images.githubusercontent.com/3953314/134464957-625e57f2-ef47-4ca1-a0a7-cd40c9b6539c.png"> <img width="631" alt="Screen Shot 2021-09-22 at 11 34 50 PM" src="https://user-images.githubusercontent.com/3953314/134464969-b2d65c0d-f8f5-497a-a832-5d805e8e0b22.png">
382 lines
13 KiB
C#
Executable file
382 lines
13 KiB
C#
Executable file
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using Server.Compression;
|
|
using Server.Logging;
|
|
|
|
namespace Server.Saves
|
|
{
|
|
public enum ArchivePeriod
|
|
{
|
|
Hourly,
|
|
Daily,
|
|
Monthly
|
|
}
|
|
|
|
public enum CompressionFormat
|
|
{
|
|
None,
|
|
Zip,
|
|
GZip,
|
|
Zstd,
|
|
}
|
|
|
|
public static class AutoArchive
|
|
{
|
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoArchive));
|
|
|
|
private static int _isArchiving;
|
|
private static DateTime _nextHourlyArchive;
|
|
private static DateTime _nextDailyArchive;
|
|
private static DateTime _nextMonthlyArchive;
|
|
private static CompressionFormat _compressionFormat;
|
|
private static bool _enablePruning;
|
|
|
|
public static Action Archive { get; set; }
|
|
public static Action Prune { get; set; }
|
|
public static string ArchivePath { get; private set; }
|
|
public static string BackupPath { get; private set; }
|
|
|
|
public static void Configure()
|
|
{
|
|
BackupPath = ServerConfiguration.GetOrUpdateSetting("autoArchive.backupPath", "Backups/Automatic");
|
|
ArchivePath = ServerConfiguration.GetOrUpdateSetting("autoArchive.archivePath", "Archives");
|
|
_compressionFormat = ServerConfiguration.GetOrUpdateSetting("autoArchive.compressionFormat", CompressionFormat.Zstd);
|
|
|
|
var useLocalArchives = ServerConfiguration.GetOrUpdateSetting("autoArchive.archiveLocally", true);
|
|
_enablePruning = ServerConfiguration.GetOrUpdateSetting("autoArchive.enableArchivePruning", true);
|
|
|
|
if (useLocalArchives)
|
|
{
|
|
Archive = AutoArchiveLocally;
|
|
}
|
|
|
|
if (_enablePruning)
|
|
{
|
|
Prune = PruneBackups;
|
|
}
|
|
|
|
// Restores an archive file placed in the Saves folder. Supports all compression formats.
|
|
RestoreFromArchive();
|
|
|
|
DateTimeOffset now = Core.Now;
|
|
var date = now.Date;
|
|
_nextHourlyArchive = date.AddHours(now.Hour);
|
|
_nextDailyArchive = date;
|
|
_nextMonthlyArchive = date.AddDays(1 - now.Day);
|
|
|
|
// Do a local archive rollup & prune
|
|
AutoArchiveLocally();
|
|
}
|
|
|
|
public static void Initialize()
|
|
{
|
|
EventSink.WorldSavePostSnapshot += Backup;
|
|
}
|
|
|
|
private static void Backup(WorldSavePostSnapshotEventArgs args)
|
|
{
|
|
if (!Directory.Exists(args.OldSavePath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var backupPath = Path.Combine(BackupPath, Utility.GetTimeStamp());
|
|
AssemblyHandler.EnsureDirectory(BackupPath);
|
|
Directory.Move(args.OldSavePath, backupPath);
|
|
|
|
logger.Information($"Created backup at {backupPath}");
|
|
|
|
Archive?.Invoke();
|
|
}
|
|
|
|
private static void RestoreFromArchive()
|
|
{
|
|
var savePath = Path.Combine(Core.BaseDirectory, ServerConfiguration.GetSetting("world.savePath", "Saves"));
|
|
|
|
var files = Directory.Exists(savePath) ? Directory.GetFiles(savePath, "????-??-??-??-??-??.*") : null;
|
|
|
|
if (files == null || files.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var latestFiles = GetInRange(files, DateTime.MinValue, DateTime.MaxValue, true);
|
|
|
|
foreach (var file in latestFiles)
|
|
{
|
|
if (RestoreFromFile(file, savePath))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool RestoreFromFile(string file, string savePath)
|
|
{
|
|
var fi = new FileInfo(file);
|
|
var fileName = fi.Name;
|
|
|
|
if (!TryGetDate(fileName[..fileName.IndexOfOrdinal(".")], out _))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
logger.Information($"Restoring latest world save from archive {fileName}");
|
|
|
|
var tempFolder = Path.Combine(Core.BaseDirectory, "temp");
|
|
AssemblyHandler.EnsureDirectory(tempFolder);
|
|
bool successful;
|
|
|
|
if (fileName.EndsWithOrdinal(".tar.zst"))
|
|
{
|
|
successful = ZstdArchive.ExtractToDirectory(fi.FullName, tempFolder);
|
|
}
|
|
else
|
|
{
|
|
TarArchive.ExtractToDirectory(fi.FullName, tempFolder);
|
|
successful = true;
|
|
}
|
|
|
|
if (!successful)
|
|
{
|
|
logger.Information($"Failed to extract {fi.Name}");
|
|
return false;
|
|
}
|
|
|
|
var allFolders = Directory.EnumerateDirectories(tempFolder, "????-??-??-??-??-??");
|
|
var worldSaveFolders = GetInRange(allFolders, DateTime.MinValue, DateTime.MaxValue);
|
|
var first = true;
|
|
foreach (var folder in worldSaveFolders)
|
|
{
|
|
if (first)
|
|
{
|
|
first = false;
|
|
Directory.Delete(savePath, true);
|
|
var dirInfo = new DirectoryInfo(folder);
|
|
logger.Information($"Restoring backup {dirInfo.Name}");
|
|
Directory.Move(folder, savePath);
|
|
}
|
|
else
|
|
{
|
|
Directory.Delete(folder, true);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public static void PruneBackups()
|
|
{
|
|
// Maintain a total of 66 of the most recent archives
|
|
PruneLocalArchives(ArchivePeriod.Hourly, 24);
|
|
PruneLocalArchives(ArchivePeriod.Daily, 30);
|
|
PruneLocalArchives(ArchivePeriod.Monthly, 12);
|
|
|
|
var allFolders = Directory.EnumerateDirectories(BackupPath, "????-??-??-??-??-??", SearchOption.AllDirectories);
|
|
var latestBackupFolders = GetInRange(allFolders, DateTime.MinValue, Core.Now.AddMonths(-1));
|
|
|
|
foreach (var folder in latestBackupFolders)
|
|
{
|
|
logger.Information($"Pruning backup {folder}");
|
|
Directory.Delete(folder, true);
|
|
}
|
|
}
|
|
|
|
private static void PruneLocalArchives(ArchivePeriod period, int minRetained)
|
|
{
|
|
var periodStr = period.ToString();
|
|
var path = Path.Combine(ArchivePath, periodStr);
|
|
var allFiles = Directory.EnumerateFiles(path, "????-??-??-??-??-??.*");
|
|
var archives = GetInRange(allFiles, DateTime.MinValue, DateTime.MaxValue, true);
|
|
|
|
var periodLowerStr = periodStr.ToLowerInvariant();
|
|
foreach (var archive in archives)
|
|
{
|
|
if (minRetained > 0)
|
|
{
|
|
minRetained--;
|
|
continue;
|
|
}
|
|
|
|
var fi = new FileInfo(archive);
|
|
logger.Information($"Pruning {periodLowerStr} archive {fi.Name}");
|
|
File.Delete(archive);
|
|
}
|
|
}
|
|
|
|
public static void AutoArchiveLocally()
|
|
{
|
|
var date = Core.Now;
|
|
|
|
if (date < _nextHourlyArchive && date < _nextDailyArchive && date < _nextMonthlyArchive)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Interlocked.CompareExchange(ref _isArchiving, 0, 1) == 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ThreadPool.UnsafeQueueUserWorkItem(
|
|
_ =>
|
|
{
|
|
if (date >= _nextHourlyArchive)
|
|
{
|
|
var lastHour = date.Date.AddHours(date.Hour);
|
|
Rollup(ArchivePeriod.Hourly, lastHour.AddHours(-1), lastHour.ToTimeStamp());
|
|
_nextHourlyArchive = _nextHourlyArchive.AddHours(2);
|
|
}
|
|
|
|
if (date >= _nextDailyArchive)
|
|
{
|
|
var yesterday = date.Date.AddDays(-1);
|
|
Rollup(ArchivePeriod.Daily, yesterday, yesterday.ToTimeStamp());
|
|
_nextDailyArchive = _nextDailyArchive.AddDays(2);
|
|
}
|
|
|
|
if (date >= _nextMonthlyArchive)
|
|
{
|
|
var lastMonth = new DateTime(date.Year, date.Month, 1).AddMonths(-1);
|
|
Rollup(ArchivePeriod.Monthly, lastMonth, lastMonth.ToTimeStamp());
|
|
_nextMonthlyArchive = _nextMonthlyArchive.AddMonths(2);
|
|
}
|
|
|
|
Prune?.Invoke();
|
|
|
|
_isArchiving = 0;
|
|
},
|
|
null
|
|
);
|
|
}
|
|
|
|
private static void Rollup(ArchivePeriod archivePeriod, DateTime rangeStart, string archiveNameNoExtension)
|
|
{
|
|
var archivePeriodStr = archivePeriod.ToString();
|
|
var archivePeriodStrLower = archivePeriodStr.ToLowerInvariant();
|
|
|
|
var stopWatch = new Stopwatch();
|
|
stopWatch.Start();
|
|
var allFolders = Directory.EnumerateDirectories(BackupPath, "????-??-??-??-??-??");
|
|
|
|
var rangeEnd = archivePeriod switch
|
|
{
|
|
ArchivePeriod.Monthly => rangeStart.AddMonths(1),
|
|
ArchivePeriod.Daily => rangeStart.AddDays(1),
|
|
_ => rangeStart.AddHours(1),
|
|
};
|
|
|
|
var latestFolders = GetInRange(allFolders, rangeStart, rangeEnd).ToList();
|
|
|
|
// We don't want to re-archive a single save. This usually means we rebooted and found the one we purposefully left behind
|
|
if (latestFolders.Count <= 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var extension = _compressionFormat.GetFileExtension();
|
|
var archiveFilePath = Path.Combine(ArchivePath, archivePeriodStr, $"{archiveNameNoExtension}{extension}");
|
|
|
|
logger.Information($"Creating {archivePeriodStrLower} archive");
|
|
var archiveCreated = _compressionFormat == CompressionFormat.Zstd
|
|
? ZstdArchive.CreateFromPaths(latestFolders, archiveFilePath)
|
|
: TarArchive.CreateFromPaths(latestFolders, archiveFilePath);
|
|
|
|
if (archiveCreated)
|
|
{
|
|
stopWatch.Stop();
|
|
var elapsed = stopWatch.Elapsed.TotalSeconds;
|
|
logger.Information($"Created {archivePeriodStrLower} archive at {archiveFilePath} ({elapsed:F2} seconds)");
|
|
|
|
// Keep the latest one, but delete the rest.
|
|
for (var i = 1; i < latestFolders.Count; i++)
|
|
{
|
|
Directory.Delete(latestFolders[i], true);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
logger.Warning($"Failed to create {archivePeriodStrLower} archive");
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> GetInRange(
|
|
IEnumerable<string> allItems,
|
|
DateTime rangeStart,
|
|
DateTime rangeEnd,
|
|
bool files = false
|
|
)
|
|
{
|
|
var items = new SortedDictionary<DateTime, string>(new DescendingComparer<DateTime>());
|
|
foreach (var item in allItems)
|
|
{
|
|
string name;
|
|
if (files)
|
|
{
|
|
var fileName = new FileInfo(item).Name;
|
|
name = fileName[..fileName.IndexOfOrdinal(".")];
|
|
}
|
|
else
|
|
{
|
|
name = new DirectoryInfo(item).Name;
|
|
}
|
|
|
|
if (IsInRange(name, rangeStart, rangeEnd, out var date))
|
|
{
|
|
items.Add(date, item);
|
|
}
|
|
}
|
|
|
|
return items.Values;
|
|
}
|
|
|
|
private static bool IsInRange(string name, DateTime start, DateTime end, out DateTime date) =>
|
|
TryGetDate(name, out date) && start <= date && end >= date;
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private static bool TryGetDate(string value, out DateTime date)
|
|
{
|
|
// Expecting YYYY-MM-DD-HH-mm-ss
|
|
var split = value.Split("-");
|
|
if (split.Length != 6)
|
|
{
|
|
date = DateTime.MinValue;
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
date = new DateTime(
|
|
int.Parse(split[0]),
|
|
int.Parse(split[1]),
|
|
int.Parse(split[2]),
|
|
int.Parse(split[3]),
|
|
int.Parse(split[4]),
|
|
int.Parse(split[5])
|
|
);
|
|
}
|
|
catch
|
|
{
|
|
date = DateTime.MinValue;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private static string GetFileExtension(this CompressionFormat compressionFormat) =>
|
|
compressionFormat switch
|
|
{
|
|
CompressionFormat.Zip => ".zip",
|
|
CompressionFormat.GZip => ".tar.gz",
|
|
CompressionFormat.Zstd => ".tar.zst",
|
|
_ => ".tar"
|
|
};
|
|
}
|
|
}
|