Fix multiple backups running at once

- Backups now have a cooldown, default of 30 seconds
- Backups now have a lock, in the event of a TOC/TOU race condition, the lock will stop duplicate backups
This commit is contained in:
DrakiaXYZ
2025-10-26 10:17:26 -07:00
parent 7d065cfb16
commit 4e73778920
3 changed files with 71 additions and 41 deletions
@@ -1,6 +1,7 @@
{
"enabled": true,
"maxBackups": 15,
"backupCooldown": 30,
"directory": "./user/profiles/backups",
"backupInterval": {
"enabled": false,
@@ -13,6 +13,9 @@ public record BackupConfig : BaseConfig
[JsonPropertyName("maxBackups")]
public int MaxBackups { get; set; }
[JsonPropertyName("backupCooldown")]
public int BackupCooldown { get; set; }
[JsonPropertyName("directory")]
public string Directory { get; set; } = string.Empty;
@@ -21,6 +21,9 @@ public class BackupService
// Runs Init() every x minutes
protected Timer _backupIntervalTimer;
protected SemaphoreSlim BackupLock = new SemaphoreSlim(1, 1);
protected long LastBackupTimestamp;
protected readonly FileUtil FileUtil;
protected readonly JsonUtil JsonUtil;
protected readonly ISptLogger<BackupService> Logger;
@@ -78,7 +81,7 @@ public class BackupService
}
/// <summary>
/// Initializes the backup process. <br />
/// Run the backup process. <br />
/// This method orchestrates the profile backup service. Handles copying profiles to a backup directory and cleaning
/// up old backups if the number exceeds the configured maximum.
/// </summary>
@@ -89,63 +92,86 @@ public class BackupService
return;
}
var targetDir = GenerateBackupTargetDir();
// Fetch all profiles in the profile directory.
List<string> currentProfilePaths;
try
// Make sure we don't back up too often by using a configurable Cooldown
var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (currentTimestamp < LastBackupTimestamp + BackupConfig.BackupCooldown)
{
currentProfilePaths = FileUtil.GetFiles(ProfileDir);
return;
}
catch (Exception ex)
LastBackupTimestamp = currentTimestamp;
// If the backup lock is already locked, skip backup. This is to stop TOC/TOU race conditions above
// Passing 0 is a non-blocking Wait, will return false if the lock can't be acquired
bool lockAcquired = await BackupLock.WaitAsync(0);
if (!lockAcquired)
{
Logger.Debug($"Skipping profile backup: Unable to read profiles directory, {ex.Message}");
return;
}
if (currentProfilePaths.Count == 0)
try
{
if (Logger.IsLogEnabled(LogLevel.Debug))
var targetDir = GenerateBackupTargetDir();
// Fetch all profiles in the profile directory.
List<string> currentProfilePaths;
try
{
Logger.Debug("No profiles to backup");
currentProfilePaths = FileUtil.GetFiles(ProfileDir);
}
catch (Exception ex)
{
Logger.Debug($"Skipping profile backup: Unable to read profiles directory, {ex.Message}");
return;
}
return;
}
try
{
FileUtil.CreateDirectory(targetDir);
foreach (var profilePath in currentProfilePaths)
if (currentProfilePaths.Count == 0)
{
// Get filename + extension, removing the path
var profileFileName = FileUtil.GetFileNameAndExtension(profilePath);
// Create absolute path to file
var relativeSourceFilePath = Path.Combine(ProfileDir, profileFileName);
var absoluteDestinationFilePath = Path.Combine(targetDir, profileFileName);
if (!FileUtil.CopyFile(relativeSourceFilePath, absoluteDestinationFilePath))
if (Logger.IsLogEnabled(LogLevel.Debug))
{
Logger.Error($"Source file not found: {relativeSourceFilePath}. Cannot copy to: {absoluteDestinationFilePath}");
Logger.Debug("No profiles to backup");
}
return;
}
try
{
FileUtil.CreateDirectory(targetDir);
foreach (var profilePath in currentProfilePaths)
{
// Get filename + extension, removing the path
var profileFileName = FileUtil.GetFileNameAndExtension(profilePath);
// Create absolute path to file
var relativeSourceFilePath = Path.Combine(ProfileDir, profileFileName);
var absoluteDestinationFilePath = Path.Combine(targetDir, profileFileName);
if (!FileUtil.CopyFile(relativeSourceFilePath, absoluteDestinationFilePath))
{
Logger.Error($"Source file not found: {relativeSourceFilePath}. Cannot copy to: {absoluteDestinationFilePath}");
}
}
// Write a copy of active mods.
await FileUtil.WriteFileAsync(Path.Combine(targetDir, activeModsFilename), JsonUtil.Serialize(ActiveServerMods));
if (Logger.IsLogEnabled(LogLevel.Debug))
{
Logger.Debug($"Profile backup created in: {targetDir}");
}
}
// Write a copy of active mods.
await FileUtil.WriteFileAsync(Path.Combine(targetDir, activeModsFilename), JsonUtil.Serialize(ActiveServerMods));
if (Logger.IsLogEnabled(LogLevel.Debug))
catch (Exception ex)
{
Logger.Debug($"Profile backup created in: {targetDir}");
Logger.Error($"Unable to write to backup profile directory: {ex.Message}");
return;
}
}
catch (Exception ex)
{
Logger.Error($"Unable to write to backup profile directory: {ex.Message}");
return;
}
CleanBackups();
CleanBackups();
}
finally
{
BackupLock.Release();
}
}
/// <summary>