The server backs itself up: every collection byte for byte, the media linked

A backup is a directory <stamp>-<kind> under Backups:Root (/var/lib/privapub/backups, 2770, files 0640), written as
.partial and renamed once whole: a manifest (host, build, newest migration, each collection's count, size, sha256 and
indexes, what was left out and why, the media list), each collection as gzipped canonical Extended JSON read raw, and
hard links to the files of untrashed media rows (copies where a link can't be made). On a replica set every collection
is read in one snapshot session. Never in a backup: the statistics salt, jobs, recovery codes, sessions, the
maintenance lock and the configuration's copy with its SMTP password.

One backup or restore at a time (MaintenanceLock, a heartbeat document), and the janitor purges nothing meanwhile.
BackupScheduler backs up nightly at 03:30 UTC (or at once after missing a night); rotation keeps 7 daily, 4 weekly,
3 pre-deploy and 3 pre-restore backups. CLI: admin backup [--kind] [--db-only], admin backups, admin backup verify;
these run before migrations, so the deploy's own pre-deploy backup, which replaces mongodump, is of the database as the
live build left it. EntityMaps.Warm runs once under a lock, since test hosts now boot side by side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 11:40:06 +02:00
1 parent 37b12c5fee
commit 12bb75809f
20 files changed
+1132 -22

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.Domain.Media;
using PrivaPub.Models;
namespace PrivaPub.Infrastructure.Backup
{
// The server's backups, wherever they are started from: the CLI, the nightly schedule, the administrator's page.
public class Backups(IOptionsMonitor<BackupOptions> options, IOptionsMonitor<AppConfiguration> app, IMediaService media)
{
/// <summary>Where backups are kept: Backups:Root (production's /var/lib/privapub/backups), else beside the media root.</summary>
public string Root => Path.GetFullPath(options.CurrentValue.Root ?? media.Root.TrimEnd(Path.DirectorySeparatorChar) + "-backups");
public BackupOptions Options => options.CurrentValue;
public string Host => app.CurrentValue.BackendBaseAddress?.TrimEnd('/');
public BackupContext Context() => new(DB.Default.Database(), Root, media.Root, media.TrashRoot, Host, options.CurrentValue);
public Task<(BackupInfo Backup, string Error)> Create(string kind, bool dbOnly, CancellationToken token) =>
ServerBackup.Create(Context(), kind, dbOnly, token);
public List<BackupInfo> List() => ServerBackup.List(Root);
public BackupInfo Find(string id) => ServerBackup.Find(Root, id);
public Task<List<string>> Verify(string id, CancellationToken token) => ServerBackup.Verify(Root, id, token);
public bool Delete(string id) => ServerBackup.Delete(Root, id);
}
// A nightly backup at Backups:NightlyAt (UTC), or as soon as the service is up after missing it; the old ones rotated
// out as each is made.
public class BackupScheduler(Backups backups, ILogger<BackupScheduler> logger) : BackgroundService
{
static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(Interval, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
try
{
await RunIfDue(DateTime.UtcNow, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "The nightly backup failed");
}
}
}
/// <summary>Backs up when the day's time has come and there is no nightly backup since: whether it did.</summary>
public async Task<bool> RunIfDue(DateTime now, CancellationToken token)
{
var options = backups.Options;
if (!options.Nightly || !TimeOnly.TryParse(options.NightlyAt, System.Globalization.CultureInfo.InvariantCulture, out var at)
|| !IsDue(now, at, backups.List().Where(b => b.Kind == "nightly").Select(b => b.CreatedAt)))
return false;
var (made, error) = await backups.Create("nightly", dbOnly: false, token);
if (made == default)
{
logger.LogWarning("The nightly backup was not made: {Error}", error);
return false;
}
logger.LogInformation("Nightly backup {Id}: {Collections} collections, {Files} media files", made.Id, made.Manifest.Collections.Count, made.Manifest.Media.Files);
return true;
}
/// <summary>Whether the last time of day for a nightly backup has passed with no nightly backup made since.</summary>
public static bool IsDue(DateTime now, TimeOnly at, IEnumerable<DateTime> nightlies)
{
var due = now.Date + at.ToTimeSpan();
if (now < due)
due = due.AddDays(-1);
return !nightlies.Any(made => made >= due);
}
}
}