Ein Plan für das, was an keinem Aufruf hängt

Sichern, Datenbank aufräumen, verwaiste Bilder wegwerfen — alle drei Tage,
alle sieben Tage, von selbst.

**Der erste Hintergrunddienst in diesem Server, und das mit Bedacht.** An
drei Stellen steht sinngemäss, ein eigener Dienst wäre Aufwand ohne
Gegenwert, und für das Wegräumen abgelaufener Gerätecodes stimmt das:
solche Arbeit hängt an einem Aufruf, der ohnehin kommt. Eine Sicherung
hängt an nichts. Sie soll alle drei Tage geschehen, auch wenn niemand etwas
anklickt, und dafür gibt es eine Uhr. Alle fünfzehn Minuten eine Frage an
eine Tabelle mit einer Handvoll Zeilen; ist nichts fällig — der Normalfall
—, kostet es nichts.

Der letzte Lauf steht in der Datenbank, nicht im Arbeitsspeicher. Ein
Server, der jede Nacht neu startet, sicherte sonst jeden Morgen — oder,
bei einem Zähler, der bei null anfängt, nie. Auch ein Fehlschlag wird
vermerkt, sonst versuchte der Plan es alle fünfzehn Minuten wieder und
eine volle Platte erzeugte ein volles Protokoll.

**Die Sicherung ist `VACUUM INTO`, nicht Datei kopieren.** Die Datenbank
läuft im WAL-Modus: neben `flaro.db` stehen `-wal` und `-shm`, und wer nur
die erste Datei kopiert, bekommt einen Stand von vor Stunden oder einen
halben. `VACUUM INTO` schreibt eine vollständige, aufgeräumte Kopie im
laufenden Betrieb. Der Zielordner ist einstellbar und geht als gebundener
Wert in die Anweisung — eine Einstellung soll keine SQL-Anweisung werden.

Die Prüfung verlässt sich nicht darauf, dass eine Datei entstanden ist: sie
öffnet die Kopie als Datenbank und zählt die Zeilen. Eine Sicherung, die
sich nicht öffnen lässt, ist schlimmer als keine — man verlässt sich
darauf und merkt es erst, wenn es zu spät ist.

**Bilder nur, wenn verwaist.** Den ganzen Bildspeicher zu leeren gibt es
als Knopf und ist gefahrlos, kostet danach aber tausende Abrufe bei TMDB.
Hier fliegt nur, worauf kein Titel mehr zeigt — mit einer Stunde
Schonfrist, weil ein Bild abgelegt wird, bevor der Verweis darauf
gespeichert ist. Ohne diese Frist räumte ein Lauf mitten im Scan genau das
weg, was gerade geholt wurde.

**Eine eigene Seite „Wartung".** Die drei Kratzplatzzahlen standen unter
„Wiedergabe" — dort ist ihr Anlass, aber nicht ihr Thema. Sie ziehen mit
um: alles auf dieser Seite ist etwas, das der Server ohne Zutun erledigt,
und man sieht es an einem Ort nach. Je Aufgabe steht da, wann sie zuletzt
lief und was herauskam, dazu ein Knopf „Jetzt ausführen" — wer gleich etwas
Grosses vorhat, will die Kopie jetzt und nicht in drei Tagen.

725 Tests grün, davon elf neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 21:02:42 +02:00
co-authored by Claude Opus 5
parent c4af182d31
commit 532473b1f9
11 changed files with 1017 additions and 63 deletions
+6 -2
View File
@@ -32,10 +32,14 @@ public sealed record SystemInfo(
/// <param name="UnusedDays">Ab wann Unbenutztes auch unterhalb der Grenze geht; <c>0</c> heisst nie.</param>
/// <param name="GraceMinutes">Wie lange frisch Gebrauchtes in jedem Fall bleibt.</param>
public sealed record MaintenanceSettingsRequest(
double? CapGb, int? UnusedDays, int? GraceMinutes);
double? CapGb, int? UnusedDays, int? GraceMinutes,
int? BackupDays = null, int? BackupKeep = null, string? BackupFolder = null,
int? OptimizeDays = null, int? ImageDays = null);
public sealed record MaintenanceSettingsResponse(
double CapGb, int UnusedDays, int GraceMinutes);
double CapGb, int UnusedDays, int GraceMinutes,
int BackupDays, int BackupKeep, string BackupFolder,
int OptimizeDays, int ImageDays);
/// <param name="Teile">
/// Die Unterordner, wo es welche gibt — heute nur beim Kratzplatz.
@@ -154,17 +154,22 @@ public static class SettingsEndpoints
/* ---------- Wartung: was auf dem Kratzplatz liegen bleiben darf ---------- */
app.MapGet("/api/v1/settings/maintenance", async (Kratzplatzeinstellungen einstellungen) =>
app.MapGet("/api/v1/settings/maintenance", async (
Kratzplatzeinstellungen kratz, Wartungsplaneinstellungen plan) =>
{
var regeln = await einstellungen.RegelnAsync();
var regeln = await kratz.RegelnAsync();
var w = await plan.RegelnAsync();
return Results.Ok(new MaintenanceSettingsResponse(
regeln.ObergrenzeBytes / 1024d / 1024 / 1024,
(int)regeln.Unbenutzt.TotalDays,
(int)regeln.Schonfrist.TotalMinutes));
(int)regeln.Schonfrist.TotalMinutes,
w.SicherungTage, w.SicherungBehalten, w.SicherungOrdner,
w.OptimierenTage, w.BilderTage));
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/settings/maintenance", async (
MaintenanceSettingsRequest anfrage, FlaroContext db, Kratzplatzeinstellungen einstellungen) =>
MaintenanceSettingsRequest anfrage, FlaroContext db,
Kratzplatzeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
{
var vorher = await einstellungen.RegelnAsync();
@@ -182,12 +187,33 @@ public static class SettingsEndpoints
((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel,
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
var wVorher = await plan.RegelnAsync();
var wNachher = Wartungsregeln.Geprueft(
anfrage.BackupDays ?? wVorher.SicherungTage,
anfrage.BackupKeep ?? wVorher.SicherungBehalten,
anfrage.BackupFolder ?? wVorher.SicherungOrdner,
anfrage.OptimizeDays ?? wVorher.OptimierenTage,
anfrage.ImageDays ?? wVorher.BilderTage);
await Setzen(db, Wartungsplaneinstellungen.SicherungTageSchluessel,
wNachher.SicherungTage.ToString(zahl));
await Setzen(db, Wartungsplaneinstellungen.SicherungBehaltenSchluessel,
wNachher.SicherungBehalten.ToString(zahl));
await Setzen(db, Wartungsplaneinstellungen.SicherungOrdnerSchluessel,
wNachher.SicherungOrdner);
await Setzen(db, Wartungsplaneinstellungen.OptimierenTageSchluessel,
wNachher.OptimierenTage.ToString(zahl));
await Setzen(db, Wartungsplaneinstellungen.BilderTageSchluessel,
wNachher.BilderTage.ToString(zahl));
await db.SaveChangesAsync();
return Results.Ok(new MaintenanceSettingsResponse(
nachher.ObergrenzeBytes / 1024d / 1024 / 1024,
(int)nachher.Unbenutzt.TotalDays,
(int)nachher.Schonfrist.TotalMinutes));
(int)nachher.Schonfrist.TotalMinutes,
wNachher.SicherungTage, wNachher.SicherungBehalten, wNachher.SicherungOrdner,
wNachher.OptimierenTage, wNachher.BilderTage));
}).RequireAuthorization(FlaroPolicies.Administrator);
}
@@ -275,6 +275,31 @@ public static class SystemEndpoints
// Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes
// von Gebrauchtem unterscheidet und einen laufenden Download in Ruhe
// lässt, ist keine Löschung eines Dings.
// **Der Wartungsplan: was wann zuletzt lief und was dabei herauskam.**
app.MapGet("/api/v1/system/maintenance", async (
Wartungsplan plan, CancellationToken ct) =>
Results.Ok(await plan.StandAsync(ct)))
.RequireAuthorization(FlaroPolicies.Administrator);
// **Jetzt ausführen, ohne auf den Plan zu warten.**
//
// Vor allem für die Sicherung: wer gleich etwas Grosses vorhat —
// eine Mediathek umbauen, eine Migration ziehen —, will vorher eine
// Kopie und nicht in drei Tagen.
app.MapPost("/api/v1/system/maintenance/{aufgabe}", async (
string aufgabe, Wartungsplan plan, CancellationToken ct) =>
{
if (!Enum.TryParse<Wartungsaufgabe>(aufgabe, ignoreCase: true, out var welche))
return Einwand.Bitte("Diese Wartungsaufgabe gibt es nicht.");
try { return Results.Ok(new { ergebnis = await plan.LaufenAsync(welche, ct) }); }
catch (Exception fehler)
{
// Der rohe Text bleibt im Protokoll; hier steht ein Satz.
return Results.Problem(Fehlertext.FuerMenschen(fehler));
}
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPost("/api/v1/system/storage/transcode/cleanup", async (
Kratzplatzpflege pflege, CancellationToken ct) =>
Results.Ok(await pflege.LaufenAsync(Kratzplatzpflege.Anlass.VonHand, ct)))
+7
View File
@@ -115,6 +115,13 @@ builder.Services.AddSingleton<Untertitelvorrat>();
// Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
builder.Services.AddScoped<Kratzplatzeinstellungen>();
builder.Services.AddSingleton<Kratzplatzpflege>();
// **Der einzige Hintergrunddienst, und mit Bedacht.** Eine Sicherung hängt
// an keinem Aufruf: sie soll alle drei Tage geschehen, auch wenn niemand
// etwas anklickt. Alles andere Wiederkehrende bleibt beiläufig.
builder.Services.AddScoped<Wartungsplaneinstellungen>();
builder.Services.AddSingleton<Wartungsplan>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<Wartungsplan>());
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
builder.Services.AddSingleton<PlaybackSessions>();
+343
View File
@@ -0,0 +1,343 @@
using System.Collections.Concurrent;
using System.Globalization;
using Flaro.Core;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Was in Abständen geschieht: sichern, aufräumen, Bilder wegwerfen.</summary>
///
/// <remarks>
/// <para>
/// <b>Der erste Hintergrunddienst in diesem Server, und das mit Bedacht.</b>
/// An drei Stellen steht sinngemäss, ein eigener Dienst wäre Aufwand ohne
/// Gegenwert — und für das Wegräumen abgelaufener Gerätecodes stimmt das:
/// solche Arbeit hängt an einem Aufruf, der ohnehin kommt. Eine Sicherung
/// hängt an nichts. Sie soll alle drei Tage geschehen, auch wenn niemand
/// etwas anklickt, und genau dafür gibt es eine Uhr.
/// </para>
/// <para>
/// <b>Die Uhr tickt, der Dienst rechnet nicht.</b> Alle fünfzehn Minuten
/// eine Frage an die Einstellungen, ob etwas fällig ist. Ist nichts fällig
/// — der Normalfall —, kostet das eine Abfrage auf eine Tabelle mit einer
/// Handvoll Zeilen.
/// </para>
/// <para>
/// <b>Der letzte Lauf steht in der Datenbank</b>, nicht im Arbeitsspeicher.
/// Ein Server, der jede Nacht neu startet, sicherte sonst jeden Morgen —
/// oder, bei einem Zähler, der bei null anfängt, nie.
/// </para>
/// </remarks>
public sealed class Wartungsplan(
IServiceScopeFactory scopes,
FlaroPaths pfade,
TimeProvider uhr,
Tagebuch tagebuch,
ILogger<Wartungsplan> log) : BackgroundService
{
/// <summary>Wie oft nachgesehen wird, ob etwas fällig ist.</summary>
///
/// <remarks>
/// Eine Viertelstunde ist fein genug für Abstände in Tagen und grob
/// genug, dass das Nachsehen selbst nicht auffällt.
/// </remarks>
private static readonly TimeSpan Takt = TimeSpan.FromMinutes(15);
/// <summary>Was zuletzt herauskam — fürs Dashboard, nicht für die Entscheidung.</summary>
private readonly ConcurrentDictionary<Wartungsaufgabe, (string? Ergebnis, string? Fehler)> _zuletzt = new();
private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
protected override async Task ExecuteAsync(CancellationToken ct)
{
// **Nicht sofort.** Beim Hochfahren hat der Server Wichtigeres zu
// tun — Migration, erster Seitenaufbau, Untertitelvorrat. Eine
// Sicherung, die zwei Minuten später anfängt, ist keine schlechtere.
try { await Task.Delay(TimeSpan.FromMinutes(2), ct); }
catch (OperationCanceledException) { return; }
using var takt = new PeriodicTimer(Takt);
do
{
try { await FaelligesLaufenAsync(ct); }
catch (OperationCanceledException) { return; }
catch (Exception fehler)
{
// Ein Fehler in der Wartung darf den Dienst nicht beenden —
// sonst fällt mit der ersten vollen Platte auch die
// Sicherung für immer aus.
log.LogError(fehler, "Der Wartungslauf ist gescheitert.");
}
}
while (await takt.WaitForNextTickAsync(ct));
}
/// <summary>Führt aus, was fällig ist.</summary>
public async Task FaelligesLaufenAsync(CancellationToken ct)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var regeln = await raum.ServiceProvider
.GetRequiredService<Wartungsplaneinstellungen>().RegelnAsync(ct);
var jetzt = uhr.GetUtcNow();
var stand = await Wartungsplaneinstellungen.Werte(db, ct);
foreach (var (aufgabe, tage) in new[]
{
(Wartungsaufgabe.Sicherung, regeln.SicherungTage),
(Wartungsaufgabe.Optimieren, regeln.OptimierenTage),
(Wartungsaufgabe.Bilder, regeln.BilderTage),
})
{
if (tage <= 0) continue;
var zuletzt = Zeit(stand, Wartungsplaneinstellungen.ZuletztSchluessel(aufgabe));
if (zuletzt is { } wann && jetzt - wann < TimeSpan.FromDays(tage)) continue;
await LaufenAsync(aufgabe, ct);
}
}
/// <summary>Führt eine Aufgabe aus, egal ob sie fällig ist.</summary>
public async Task<string> LaufenAsync(Wartungsaufgabe aufgabe, CancellationToken ct)
{
await _einerZurZeit.WaitAsync(ct);
try
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var regeln = await raum.ServiceProvider
.GetRequiredService<Wartungsplaneinstellungen>().RegelnAsync(ct);
string ergebnis;
try
{
ergebnis = aufgabe switch
{
Wartungsaufgabe.Sicherung => await SichernAsync(db, regeln, ct),
Wartungsaufgabe.Optimieren => await OptimierenAsync(db, ct),
Wartungsaufgabe.Bilder => await BilderAufraeumenAsync(db, ct),
_ => "Unbekannte Aufgabe.",
};
_zuletzt[aufgabe] = (ergebnis, null);
tagebuch.Schreiben($"Wartung — {aufgabe}: {ergebnis}");
}
catch (Exception fehler)
{
_zuletzt[aufgabe] = (null, Fehlertext.FuerMenschen(fehler));
log.LogError(fehler, "Die Wartungsaufgabe {Aufgabe} ist gescheitert.", aufgabe);
tagebuch.Schreiben($"Wartung — {aufgabe} gescheitert: {Fehlertext.FuerMenschen(fehler)}",
Tagebuch.Art.Fehler);
throw;
}
finally
{
// **Auch nach einem Fehlschlag merken.** Sonst versucht der
// Plan es alle fünfzehn Minuten wieder, und eine volle
// Platte erzeugt ein volles Protokoll.
await Merken(db, Wartungsplaneinstellungen.ZuletztSchluessel(aufgabe),
uhr.GetUtcNow().ToString("O", CultureInfo.InvariantCulture), ct);
}
return ergebnis;
}
finally
{
_einerZurZeit.Release();
}
}
/// <summary>Der Stand aller Aufgaben — für die Oberfläche.</summary>
public async Task<IReadOnlyList<Wartungsstand>> StandAsync(CancellationToken ct)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var regeln = await raum.ServiceProvider
.GetRequiredService<Wartungsplaneinstellungen>().RegelnAsync(ct);
var werte = await Wartungsplaneinstellungen.Werte(db, ct);
return new[]
{
(Wartungsaufgabe.Sicherung, regeln.SicherungTage),
(Wartungsaufgabe.Optimieren, regeln.OptimierenTage),
(Wartungsaufgabe.Bilder, regeln.BilderTage),
}.Select(x =>
{
var zuletzt = Zeit(werte, Wartungsplaneinstellungen.ZuletztSchluessel(x.Item1));
var (ergebnis, fehler) = _zuletzt.GetValueOrDefault(x.Item1);
return new Wartungsstand(
x.Item1.ToString(), x.Item2, zuletzt,
x.Item2 > 0 ? (zuletzt ?? uhr.GetUtcNow()).AddDays(x.Item2) : null,
ergebnis, fehler);
}).ToList();
}
/* ---------- Die Aufgaben ---------- */
/// <summary>Eine Kopie der Datenbank, in sich stimmig.</summary>
///
/// <remarks>
/// <b><c>VACUUM INTO</c> statt Datei kopieren.</b> Die Datenbank läuft im
/// WAL-Modus: neben <c>flaro.db</c> stehen <c>-wal</c> und <c>-shm</c>,
/// und wer nur die erste Datei kopiert, bekommt einen Stand von vor
/// Stunden — oder einen halben. <c>VACUUM INTO</c> schreibt eine
/// vollständige, aufgeräumte Kopie, während der Betrieb weiterläuft.
/// </remarks>
private async Task<string> SichernAsync(FlaroContext db, Wartungsregeln regeln, CancellationToken ct)
{
var ordner = regeln.SicherungOrdner.Length > 0
? regeln.SicherungOrdner
: Path.Combine(pfade.Data, "sicherungen");
Directory.CreateDirectory(ordner);
var name = $"flaro-{uhr.GetUtcNow():yyyy-MM-dd-HHmm}.db";
var ziel = Path.Combine(ordner, name);
// VACUUM INTO legt die Datei selbst an und bricht ab, wenn sie steht.
if (File.Exists(ziel)) File.Delete(ziel);
// **Als Parameter, nicht in die Anweisung geschrieben.** Der Ordner
// ist eine Einstellung; ihn in den Text zu kleben hiesse, eine
// Einstellung zu einer SQL-Anweisung zu machen. SQLite nimmt an
// dieser Stelle einen gebundenen Wert.
await db.Database.ExecuteSqlRawAsync("VACUUM INTO {0}", [ziel], ct);
var weg = Aufbewahren(ordner, regeln.SicherungBehalten);
var gross = new FileInfo(ziel).Length;
return $"{name}, {gross / (1024 * 1024)} MB"
+ (weg > 0 ? $", {weg} ältere entfernt" : "");
}
/// <summary>Behält die neuesten Sicherungen und wirft den Rest weg.</summary>
private int Aufbewahren(string ordner, int behalten)
{
var weg = 0;
foreach (var alt in Directory.EnumerateFiles(ordner, "flaro-*.db")
.OrderByDescending(d => d)
.Skip(behalten))
{
try { File.Delete(alt); weg++; }
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
log.LogDebug(fehler, "Die alte Sicherung {Datei} liess sich nicht löschen.", alt);
}
}
return weg;
}
/// <summary>Räumt die Datenbank auf.</summary>
///
/// <remarks>
/// <c>PRAGMA optimize</c> frischt die Statistiken auf, nach denen SQLite
/// seine Abfragepläne wählt; <c>VACUUM</c> packt die Datei zusammen.
/// Beides sperrt kurz — deshalb nicht bei jedem Start, sondern nach Plan.
/// </remarks>
private async Task<string> OptimierenAsync(FlaroContext db, CancellationToken ct)
{
var pfad = pfade.DatabaseFile;
var vorher = File.Exists(pfad) ? new FileInfo(pfad).Length : 0;
await db.Database.ExecuteSqlRawAsync("PRAGMA optimize;", ct);
await db.Database.ExecuteSqlRawAsync("VACUUM;", ct);
var nachher = File.Exists(pfad) ? new FileInfo(pfad).Length : 0;
var frei = vorher - nachher;
return frei > 0
? $"{frei / 1024} KB kleiner"
: "nichts zusammenzupacken";
}
/// <summary>Wirft Bilder weg, auf die kein Titel mehr zeigt.</summary>
///
/// <remarks>
/// <b>Warum nicht einfach alles.</b> Den Bildspeicher zu leeren gibt es
/// als Knopf, und es ist gefahrlos — nur holt der Server danach jedes
/// Plakat neu, und das sind bei achthundert Titeln ein paar tausend
/// Abrufe bei TMDB. Hier fliegt nur, worauf nichts mehr zeigt.
///
/// <b>Eine Stunde Schonfrist.</b> Ein Bild wird abgelegt, bevor der
/// Verweis darauf gespeichert ist. Ohne diese Frist räumte ein Lauf
/// mitten im Scan genau das weg, was gerade geholt wurde.
/// </remarks>
private async Task<string> BilderAufraeumenAsync(FlaroContext db, CancellationToken ct)
{
var ordner = Path.Combine(pfade.Cache, "images");
if (!Directory.Exists(ordner)) return "kein Bildspeicher da";
var benutzt = new HashSet<string>(StringComparer.Ordinal);
void Sammeln(IEnumerable<string?> schluessel)
{
foreach (var s in schluessel) if (!string.IsNullOrEmpty(s)) benutzt.Add(s);
}
// **Drei Spalten je Zeile, nicht drei Abfragen.** `SelectMany` über
// ein frisch gebautes Feld lässt sich nicht nach SQL übersetzen —
// EF wirft dafür. Also je Zeile ein Tripel holen und hier auffalten.
foreach (var m in await db.Movies.AsNoTracking()
.Select(m => new { m.PosterImage, m.BackdropImage, m.LogoImage }).ToListAsync(ct))
{
Sammeln([m.PosterImage, m.BackdropImage, m.LogoImage]);
}
foreach (var r in await db.Series.AsNoTracking()
.Select(r => new { r.PosterImage, r.BackdropImage, r.LogoImage }).ToListAsync(ct))
{
Sammeln([r.PosterImage, r.BackdropImage, r.LogoImage]);
}
Sammeln(await db.Episodes.AsNoTracking().Select(e => e.StillImage).ToListAsync(ct));
Sammeln(await db.People.AsNoTracking().Select(p => p.Image).ToListAsync(ct));
Sammeln(await db.Users.AsNoTracking().Select(u => u.AvatarImage).ToListAsync(ct));
var jetzt = uhr.GetUtcNow();
long frei = 0;
var zahl = 0;
foreach (var datei in Directory.EnumerateFiles(ordner, "*.img", SearchOption.AllDirectories))
{
var schluessel = Path.GetFileNameWithoutExtension(datei);
if (benutzt.Contains(schluessel)) continue;
try
{
var info = new FileInfo(datei);
if (jetzt - new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero) < TimeSpan.FromHours(1)) continue;
var gross = info.Length;
File.Delete(datei);
frei += gross;
zahl++;
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
log.LogDebug(fehler, "{Datei} liess sich nicht löschen.", datei);
}
}
return zahl == 0 ? "nichts verwaist" : $"{zahl} Bild(er), {frei / 1024} KB frei";
}
/* ---------- Kleinkram ---------- */
private static DateTimeOffset? Zeit(IReadOnlyDictionary<string, string> werte, string schluessel) =>
werte.TryGetValue(schluessel, out var roh)
&& DateTimeOffset.TryParse(roh, CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out var wann)
? wann
: null;
private static async Task Merken(FlaroContext db, string schluessel, string wert, CancellationToken ct)
{
var zeile = await db.Settings.FindAsync([schluessel], ct);
if (zeile is null) db.Settings.Add(new ServerSetting { Key = schluessel, Value = wert });
else zeile.Value = wert;
await db.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,114 @@
using System.Globalization;
using Flaro.Core;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Was regelmässig geschieht und wie oft.</summary>
///
/// <param name="SicherungTage">Abstand zwischen zwei Sicherungen; <c>0</c> heisst: keine.</param>
/// <param name="SicherungBehalten">Wie viele Sicherungen liegen bleiben.</param>
/// <param name="SicherungOrdner">Wohin; leer heisst: neben die Datenbank.</param>
/// <param name="OptimierenTage">Abstand fürs Aufräumen der Datenbank; <c>0</c> heisst: nie.</param>
/// <param name="BilderTage">Abstand fürs Wegräumen verwaister Bilder; <c>0</c> heisst: nie.</param>
public sealed record Wartungsregeln(
int SicherungTage,
int SicherungBehalten,
string SicherungOrdner,
int OptimierenTage,
int BilderTage)
{
/// <remarks>
/// <b>Alle drei Tage sichern, fünf behalten.</b> Was man verliert, ist
/// höchstens das, was seit der letzten Sicherung dazukam — bei drei Tagen
/// also ein paar Sehstände und vielleicht ein Scan. Fünf Stände decken
/// zwei Wochen ab, und das reicht, um einen Fehler zu bemerken, bevor
/// die letzte heile Kopie verfallen ist.
/// </remarks>
public static Wartungsregeln Vorgabe { get; } = new(3, 5, "", 7, 7);
public static Wartungsregeln Geprueft(
int sicherungTage, int behalten, string? ordner, int optimierenTage, int bilderTage) =>
new(Math.Clamp(sicherungTage, 0, 365),
Math.Clamp(behalten, 1, 100),
(ordner ?? "").Trim(),
Math.Clamp(optimierenTage, 0, 365),
Math.Clamp(bilderTage, 0, 365));
}
/// <summary>Liest die Wartungsregeln aus den Einstellungen.</summary>
///
/// <remarks>
/// Nach dem Vorbild von <see cref="Wiedergabeeinstellungen"/>. Die Tabelle
/// ist generisch; es braucht keine Migration.
/// </remarks>
public sealed class Wartungsplaneinstellungen(FlaroContext db)
{
public const string SicherungTageSchluessel = "wartung.sicherung.tage";
public const string SicherungBehaltenSchluessel = "wartung.sicherung.behalten";
public const string SicherungOrdnerSchluessel = "wartung.sicherung.ordner";
public const string OptimierenTageSchluessel = "wartung.optimieren.tage";
public const string BilderTageSchluessel = "wartung.bilder.tage";
/// <summary>Wann eine Aufgabe zuletzt gelaufen ist — ebenfalls in den Einstellungen.</summary>
///
/// <remarks>
/// <b>Und nicht im Arbeitsspeicher.</b> Ein Server, der jede Nacht neu
/// startet, führte sonst jeden Morgen eine Sicherung aus — oder, wenn
/// der Zähler bei null anfinge, nie eine.
/// </remarks>
public static string ZuletztSchluessel(Wartungsaufgabe aufgabe) => $"wartung.{aufgabe}.zuletzt".ToLowerInvariant();
private Wartungsregeln? _gemerkt;
public async Task<Wartungsregeln> RegelnAsync(CancellationToken ct = default)
{
if (_gemerkt is { } fertig) return fertig;
var werte = await Werte(db, ct);
var vorgabe = Wartungsregeln.Vorgabe;
_gemerkt = Wartungsregeln.Geprueft(
(int)Zahl(werte, SicherungTageSchluessel, vorgabe.SicherungTage),
(int)Zahl(werte, SicherungBehaltenSchluessel, vorgabe.SicherungBehalten),
werte.GetValueOrDefault(SicherungOrdnerSchluessel, vorgabe.SicherungOrdner),
(int)Zahl(werte, OptimierenTageSchluessel, vorgabe.OptimierenTage),
(int)Zahl(werte, BilderTageSchluessel, vorgabe.BilderTage));
return _gemerkt;
}
internal static async Task<Dictionary<string, string>> Werte(FlaroContext db, CancellationToken ct) =>
await db.Settings.AsNoTracking()
.Where(e => e.Key.StartsWith("wartung."))
.ToDictionaryAsync(e => e.Key, e => e.Value, ct);
private static double Zahl(IReadOnlyDictionary<string, string> werte, string schluessel, double vorgabe) =>
werte.TryGetValue(schluessel, out var roh)
&& double.TryParse(roh, NumberStyles.Float, CultureInfo.InvariantCulture, out var wert)
? wert
: vorgabe;
}
/// <summary>Die Aufgaben, die der Plan kennt.</summary>
public enum Wartungsaufgabe
{
/// <summary>Eine Kopie der Datenbank.</summary>
Sicherung,
/// <summary>Die Datenbank aufräumen.</summary>
Optimieren,
/// <summary>Bilder wegräumen, zu denen es keinen Titel mehr gibt.</summary>
Bilder,
}
/// <summary>Was eine Aufgabe zuletzt getan hat.</summary>
public sealed record Wartungsstand(
string Aufgabe,
int Tage,
DateTimeOffset? Zuletzt,
DateTimeOffset? Faellig,
string? Ergebnis,
string? Fehler);
@@ -0,0 +1,267 @@
using System.Net;
using System.Net.Http.Json;
using Flaro.Api;
using Flaro.Api.Endpoints;
using Microsoft.Data.Sqlite;
namespace Flaro.Api.Tests;
/// <summary>Sichern, aufräumen, Bilder wegwerfen — und was dabei herauskommt.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Sicherung ist das Stück, das im Ernstfall zählt.</b> Eine Kopie,
/// die sich nicht öffnen lässt, ist schlimmer als keine: man verlässt sich
/// darauf und merkt es erst, wenn es zu spät ist. Deshalb prüft der Test
/// nicht, dass eine Datei entstanden ist, sondern dass sie sich als
/// Datenbank öffnen lässt und die Zeilen enthält.
/// </para>
/// <para>
/// <b>Warum nicht einfach die Datei kopieren.</b> Die Datenbank läuft im
/// WAL-Modus: neben <c>flaro.db</c> stehen <c>-wal</c> und <c>-shm</c>, und
/// wer nur die erste kopiert, bekommt einen Stand von vor Stunden oder
/// einen halben. <c>VACUUM INTO</c> schreibt eine vollständige Kopie im
/// laufenden Betrieb — genau das hält dieser Test fest.
/// </para>
/// </remarks>
[TestFixture]
public sealed class WartungsplanTests
{
private static async Task<HttpClient> AlsAdmin(FlaroServer server)
{
var admin = await server.SetUpAndSignIn();
return server.AlsAngemeldeter(admin.Token);
}
private static Task<HttpResponseMessage> Ausfuehren(HttpClient client, string aufgabe) =>
client.PostAsync($"/api/v1/system/maintenance/{aufgabe}", null);
private static async Task<string> Datenordner(HttpClient client)
{
var orte = await client.GetFromJsonAsync<List<Speicherort>>("/api/v1/system/storage");
return orte!.First(o => o.Variable == "FLARO_DATA").Pfad;
}
/// <summary>Die Sicherung ergibt eine Datenbank, die sich öffnen lässt.</summary>
[Test]
public async Task Die_Sicherung_ist_eine_lesbare_Datenbank()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var antwort = await Ausfuehren(client, "sicherung");
antwort.EnsureSuccessStatusCode();
var ordner = Path.Combine(await Datenordner(client), "sicherungen");
var kopien = Directory.GetFiles(ordner, "flaro-*.db");
Assert.That(kopien, Has.Length.EqualTo(1));
// Die Probe aufs Exempel: öffnen und zählen.
await using var verbindung = new SqliteConnection($"Data Source={kopien[0]};Mode=ReadOnly");
await verbindung.OpenAsync();
await using var befehl = verbindung.CreateCommand();
befehl.CommandText = "SELECT COUNT(*) FROM Users";
Assert.That(Convert.ToInt32(await befehl.ExecuteScalarAsync()), Is.EqualTo(1),
"der eingerichtete Administrator muss in der Kopie stehen");
}
/// <summary>Es bleiben so viele Sicherungen liegen, wie eingestellt ist.</summary>
///
/// <remarks>
/// Ohne Grenze füllt eine Sicherung alle drei Tage über ein Jahr
/// hundertzwanzig Kopien derselben Datenbank an — auf demselben Volume,
/// das sie schützen soll.
/// </remarks>
[Test]
public async Task Alte_Sicherungen_werden_weggeraeumt()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
new MaintenanceSettingsRequest(null, null, null, BackupKeep: 2));
var ordner = Path.Combine(await Datenordner(client), "sicherungen");
Directory.CreateDirectory(ordner);
// Drei ältere Stände, die der Lauf überzählig macht.
foreach (var tag in new[] { "2020-01-01-0000", "2020-01-02-0000", "2020-01-03-0000" })
await File.WriteAllTextAsync(Path.Combine(ordner, $"flaro-{tag}.db"), "alt");
(await Ausfuehren(client, "sicherung")).EnsureSuccessStatusCode();
Assert.That(Directory.GetFiles(ordner, "flaro-*.db"), Has.Length.EqualTo(2),
"die neue und die neueste alte");
}
/// <summary>Die Sicherung landet im eingestellten Ordner.</summary>
[Test]
public async Task Ein_eigener_Ordner_wird_benutzt()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var eigen = Path.Combine(Path.GetTempPath(), "flaro-sicherung-" + Guid.CreateVersion7().ToString("N"));
try
{
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
new MaintenanceSettingsRequest(null, null, null, BackupFolder: eigen));
(await Ausfuehren(client, "sicherung")).EnsureSuccessStatusCode();
Assert.That(Directory.GetFiles(eigen, "flaro-*.db"), Has.Length.EqualTo(1));
}
finally
{
try { Directory.Delete(eigen, recursive: true); } catch (IOException) { /* Kür */ }
}
}
/// <summary>Das Aufräumen der Datenbank läuft durch und meldet etwas.</summary>
[Test]
public async Task Die_Datenbank_laesst_sich_optimieren()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var antwort = await Ausfuehren(client, "optimieren");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
/// <summary>Ein Bild, auf das nichts zeigt, fliegt — ein benutztes nicht.</summary>
///
/// <remarks>
/// <b>Die Schonfrist ist der Grund für die Zeitstempel.</b> Ein Bild
/// wird abgelegt, bevor der Verweis darauf gespeichert ist; ohne Frist
/// räumte ein Lauf mitten im Scan genau das weg, was gerade geholt wurde.
/// </remarks>
[Test]
public async Task Verwaiste_Bilder_fliegen_frische_bleiben()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var orte = await client.GetFromJsonAsync<List<Speicherort>>("/api/v1/system/storage");
var bilder = Path.Combine(orte!.First(o => o.Variable == "FLARO_CACHE").Pfad, "images", "ab");
Directory.CreateDirectory(bilder);
var alt = Path.Combine(bilder, "abverwaistesbild.img");
var frisch = Path.Combine(bilder, "abfrischesbild.img");
await File.WriteAllBytesAsync(alt, new byte[256]);
await File.WriteAllBytesAsync(frisch, new byte[256]);
File.SetLastWriteTimeUtc(alt, DateTime.UtcNow.AddDays(-1));
(await Ausfuehren(client, "bilder")).EnsureSuccessStatusCode();
Assert.Multiple(() =>
{
Assert.That(File.Exists(alt), Is.False, "auf dieses Bild zeigt nichts mehr");
Assert.That(File.Exists(frisch), Is.True,
"das könnte gerade erst abgelegt und noch nicht verknüpft sein");
});
}
/// <summary>Der Stand nennt jede Aufgabe mit ihrem Abstand.</summary>
[Test]
public async Task Der_Stand_nennt_alle_drei_Aufgaben()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var stand = await client.GetFromJsonAsync<List<Wartungsstand>>("/api/v1/system/maintenance");
Assert.Multiple(() =>
{
Assert.That(stand!.Select(s => s.Aufgabe),
Is.EquivalentTo(new[] { "Sicherung", "Optimieren", "Bilder" }));
Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Tage, Is.EqualTo(3));
Assert.That(stand.All(s => s.Zuletzt is null), Is.True, "noch lief keine");
});
}
/// <summary>Nach einem Lauf steht da, wann er war und was herauskam.</summary>
[Test]
public async Task Nach_einem_Lauf_steht_der_Zeitpunkt_fest()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
await Ausfuehren(client, "sicherung");
var stand = await client.GetFromJsonAsync<List<Wartungsstand>>("/api/v1/system/maintenance");
var sicherung = stand!.First(s => s.Aufgabe == "Sicherung");
Assert.Multiple(() =>
{
Assert.That(sicherung.Zuletzt, Is.Not.Null);
Assert.That(sicherung.Faellig, Is.Not.Null, "und wann die nächste ansteht");
Assert.That(sicherung.Ergebnis, Does.Contain("MB"));
});
}
[Test]
public async Task Eine_unbekannte_Aufgabe_wird_abgewiesen()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var antwort = await Ausfuehren(client, "kaffeekochen");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Wartung_verlangt_Administratorrechte()
{
using var server = new FlaroServer();
var admin = await AlsAdmin(server);
var gast = await server.NeuerBenutzer(admin, "gast", "gastpasswortlang");
Assert.Multiple(async () =>
{
Assert.That((await Ausfuehren(gast, "sicherung")).StatusCode,
Is.EqualTo(HttpStatusCode.Forbidden));
Assert.That((await gast.GetAsync("/api/v1/system/maintenance")).StatusCode,
Is.EqualTo(HttpStatusCode.Forbidden));
});
}
/// <summary>Ein Abstand von null heisst: diese Aufgabe läuft nicht von selbst.</summary>
[Test]
public async Task Abgeschaltet_bedeutet_kein_Faelligkeitstermin()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
new MaintenanceSettingsRequest(null, null, null, BackupDays: 0));
var stand = await client.GetFromJsonAsync<List<Wartungsstand>>("/api/v1/system/maintenance");
Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Faellig, Is.Null);
}
/// <summary>Die Einstellungen des Plans kommen zurück, wie sie gesetzt wurden.</summary>
[Test]
public async Task Die_Vorgaben_des_Plans_stehen_und_lassen_sich_aendern()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var vorher = await client.GetFromJsonAsync<MaintenanceSettingsResponse>("/api/v1/settings/maintenance");
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
new MaintenanceSettingsRequest(null, null, null, BackupDays: 1, BackupKeep: 10));
var nachher = await client.GetFromJsonAsync<MaintenanceSettingsResponse>("/api/v1/settings/maintenance");
Assert.Multiple(() =>
{
Assert.That(vorher!.BackupDays, Is.EqualTo(3));
Assert.That(vorher.OptimizeDays, Is.EqualTo(7));
Assert.That(nachher!.BackupDays, Is.EqualTo(1));
Assert.That(nachher.BackupKeep, Is.EqualTo(10));
Assert.That(nachher.OptimizeDays, Is.EqualTo(7), "was nicht geschickt wurde, bleibt");
Assert.That(nachher.CapGb, Is.EqualTo(20).Within(0.01), "und der Kratzplatz auch");
});
}
}