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>
344 lines
14 KiB
C#
344 lines
14 KiB
C#
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);
|
|
}
|
|
}
|